Webdesign-Bereich: Fundament, oeffentliche Seiten, Zugangsschutz, PayPal
Umsetzung des "Website Masterplan" (16 Seiten) unter /webdesign. Zugangsschutz mit EIGENER Schranke (server/webdesign-gate.js) statt gate.js: gate.js laesst seit dem oeffentlichen Start am 21.08.2026 jeden durch, weil die Pruefung auf SITE_PUBLIC_LAUNCH_AT als allererste Zeile steht. Haette man /webdesign dahintergehaengt, waere der ausdruecklich nicht-oeffentliche Bereich inklusive Preisen und spaeteren Kundendaten ab der ersten Sekunde fuer jeden lesbar gewesen. Eigenes Sitzungs-Cookie, bereich="webdesign" im Token, damit ein gueltiges Universe-Cookie hier NICHT gilt. 37/37 Tests. Sieben oeffentliche Seiten in fuenf Sprachen (de, de-CH mit echtem Dialekt, en, fr, pt). Preise, Zeitrahmen, Paketnamen und die 30-%-Regel stehen an genau EINER Stelle in wd-core.js -- der Masterplan verlangt "ueberall widerspruchsfrei", und vier Kopien laufen bei der ersten Preisaenderung auseinander. Als App installierbar auf Handy und PC. Der Service Worker speichert bewusst KEINE HTML-Seite zwischen: nach dem Abmelden wuerden sonst geschuetzte Seiten weiter ausgeliefert, ohne dass der Server je gefragt wird. 18/18 Tests. Handy-Abnahme ueber alle Seiten in fuenf Breiten (320-1440) und fuenf Sprachen: 40/40. Der Test fand 35 echte Fehler (Touch-Ziele unter 44px), behoben im Designsystem statt einzeln pro Seite. PayPal (Wunsch 22.08.2026 "sofort auf meinem paypal"): Orders API mit intent=CAPTURE, also sofortiger Einzug statt blosser Reservierung. Gebuehr und Nettobetrag getrennt gespeichert. Betraege durchgehend als Ganzzahl in Cent. Gefaelschte Webhooks werden abgewiesen. Fail closed solange Zugangsdaten fehlen. 28/28 Tests gegen einen nachgebauten PayPal-Server. Datenbank: 15 Tabellen mit Praefix wd_, fachlich vollstaendig vom Universe getrennt. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
/* =====================================================================
|
||||
test-webdesign-paypal.mjs — Selbsttest der PayPal-Anbindung.
|
||||
|
||||
Läuft OHNE echte PayPal-Zugangsdaten: ein nachgebauter PayPal-Server
|
||||
antwortet auf 127.0.0.1 statt api-m.paypal.com. Dadurch lässt sich
|
||||
alles prüfen, was schiefgehen kann, ohne dass Geld bewegt wird und ohne
|
||||
dass Filipe vorher ein Konto einrichten muss.
|
||||
|
||||
Aufruf: node test-webdesign-paypal.mjs
|
||||
===================================================================== */
|
||||
|
||||
/* Bewusst OHNE Fremdpakete (kein express): server-internal braucht
|
||||
better-sqlite3, das auf Windows einen C++-Compiler zum Uebersetzen
|
||||
verlangt. Ein Test, der sich nur mit vollstaendig eingerichteter
|
||||
Entwicklungsumgebung ausfuehren laesst, wird nicht ausgefuehrt --
|
||||
und damit ist er wertlos. node:http reicht hier voellig. */
|
||||
import { createServer } from "node:http";
|
||||
|
||||
let bestanden = 0, gescheitert = 0;
|
||||
function pruefe(name, bedingung, zusatz = "") {
|
||||
if (bedingung) { bestanden++; console.log(" ok " + name); }
|
||||
else { gescheitert++; console.log(" FEHLT " + name + " " + zusatz); }
|
||||
}
|
||||
|
||||
console.log("\n=== PayPal-Anbindung Webdesign ===\n");
|
||||
|
||||
/* ---------- 1. Rechnen mit Geld ---------- */
|
||||
console.log("1. Betraege");
|
||||
const { centZuPaypal, paypalZuCent } = await import("./lib/webdesign-paypal.js");
|
||||
{
|
||||
const faelle = [
|
||||
[30000, "300.00"], [50000, "500.00"], [100000, "1000.00"],
|
||||
[4900, "49.00"], [1, "0.01"], [99, "0.99"], [100, "1.00"],
|
||||
[123456, "1234.56"], [0, "0.00"],
|
||||
];
|
||||
let alle = true;
|
||||
for (const [cent, erwartet] of faelle) {
|
||||
if (centZuPaypal(cent) !== erwartet) { alle = false; console.log(` ${cent} -> ${centZuPaypal(cent)}, erwartet ${erwartet}`); }
|
||||
}
|
||||
pruefe("Cent -> PayPal-Format", alle);
|
||||
|
||||
let rueck = true;
|
||||
for (const [cent, txt] of faelle) if (paypalZuCent(txt) !== cent) { rueck = false; console.log(` ${txt} -> ${paypalZuCent(txt)}, erwartet ${cent}`); }
|
||||
pruefe("PayPal-Format -> Cent", rueck);
|
||||
|
||||
/* Der eigentliche Grund fuer die Ganzzahl-Rechnung: mit Gleitkomma
|
||||
entstehen genau hier falsche Betraege. */
|
||||
let rundung = true;
|
||||
for (let c = 0; c <= 200000; c += 7) {
|
||||
if (paypalZuCent(centZuPaypal(c)) !== c) { rundung = false; console.log(" Rundungsfehler bei " + c); break; }
|
||||
}
|
||||
pruefe("Hin- und Rueckrechnung ueber 28.000 Betraege verlustfrei", rundung);
|
||||
|
||||
let wirft = false;
|
||||
try { centZuPaypal(30.5); } catch { wirft = true; }
|
||||
pruefe("Kommazahl wird abgelehnt", wirft);
|
||||
wirft = false;
|
||||
try { centZuPaypal(-100); } catch { wirft = true; }
|
||||
pruefe("negativer Betrag wird abgelehnt", wirft);
|
||||
pruefe("unsinniges PayPal-Format ergibt null", paypalZuCent("abc") === null && paypalZuCent("1.234") === null);
|
||||
}
|
||||
|
||||
/* ---------- 2. Fail closed ---------- */
|
||||
console.log("\n2. Ohne Zugangsdaten wird gesperrt");
|
||||
{
|
||||
const gemerkt = { ...process.env };
|
||||
delete process.env.PAYPAL_CLIENT_ID;
|
||||
delete process.env.PAYPAL_CLIENT_SECRET;
|
||||
|
||||
const m = await import("./lib/webdesign-paypal.js");
|
||||
pruefe("istEingerichtet meldet false", m.istEingerichtet() === false);
|
||||
pruefe("fehlende Werte werden benannt",
|
||||
m.fehlendeEinstellungen().includes("PAYPAL_CLIENT_ID"));
|
||||
|
||||
let fehler = null;
|
||||
try { await m.bestellungAnlegen({ zahlungId: "x", nummer: "Z-1", betragCent: 100 }); }
|
||||
catch (e) { fehler = e; }
|
||||
pruefe("Bestellung anlegen wirft statt still zu scheitern", fehler?.code === "PAYPAL_NICHT_EINGERICHTET");
|
||||
pruefe("Fehlermeldung nennt die fehlende Einstellung", /PAYPAL_CLIENT_ID/.test(fehler?.message || ""));
|
||||
|
||||
let fehler2 = null;
|
||||
try { await m.bestellungEinziehen("ORDER-1"); } catch (e) { fehler2 = e; }
|
||||
pruefe("Einzug wirft ebenfalls", fehler2?.code === "PAYPAL_NICHT_EINGERICHTET");
|
||||
|
||||
Object.assign(process.env, gemerkt);
|
||||
}
|
||||
|
||||
/* ---------- 3. Gegen einen nachgebauten PayPal-Server ---------- */
|
||||
console.log("\n3. Vollstaendiger Ablauf gegen einen nachgebauten PayPal-Server");
|
||||
{
|
||||
const gesehen = { requestIds: [], intents: [], betraege: [] };
|
||||
|
||||
/* Nachgebauter PayPal-Server. Antwortet nur auf die Pfade, die wir
|
||||
tatsaechlich aufrufen -- alles andere gibt 404, damit ein Tippfehler
|
||||
im Pfad sofort auffaellt statt still ins Leere zu laufen. */
|
||||
const server = createServer((req, res) => {
|
||||
let koerper = "";
|
||||
req.on("data", (stueck) => { koerper += stueck; });
|
||||
req.on("end", () => {
|
||||
const antworte = (code, daten) => {
|
||||
res.writeHead(code, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(daten));
|
||||
};
|
||||
/* Nur JSON auspacken. Der Token-Aufruf schickt bewusst
|
||||
form-urlencoded ("grant_type=client_credentials") -- ein
|
||||
bedingungsloses JSON.parse ist hier genau der Fehler, den der
|
||||
echte PayPal-Server auch machen wuerde. */
|
||||
const istJson = (req.headers["content-type"] || "").includes("json");
|
||||
let body = {};
|
||||
if (istJson && koerper) {
|
||||
try { body = JSON.parse(koerper); }
|
||||
catch { return antworte(400, { error: "kein gueltiges JSON" }); }
|
||||
}
|
||||
|
||||
if (req.url === "/v1/oauth2/token") {
|
||||
const auth = req.headers.authorization || "";
|
||||
if (!auth.startsWith("Basic ")) return antworte(401, { error: "kein Basic" });
|
||||
return antworte(200, { access_token: "test-token", expires_in: 3600 });
|
||||
}
|
||||
|
||||
if (req.url === "/v2/checkout/orders" && req.method === "POST") {
|
||||
gesehen.requestIds.push(req.headers["paypal-request-id"]);
|
||||
gesehen.intents.push(body.intent);
|
||||
gesehen.betraege.push(body.purchase_units[0].amount.value);
|
||||
return antworte(201, {
|
||||
id: "ORDER-TEST-1",
|
||||
status: "PAYER_ACTION_REQUIRED",
|
||||
links: [
|
||||
{ rel: "self", href: "x" },
|
||||
{ rel: "payer-action", href: "https://sandbox.paypal.com/checkoutnow?token=ORDER-TEST-1" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (/^\/v2\/checkout\/orders\/[^/]+\/capture$/.test(req.url)) {
|
||||
return antworte(200, {
|
||||
id: "ORDER-TEST-1",
|
||||
status: "COMPLETED",
|
||||
payer: { email_address: "[email protected]" },
|
||||
purchase_units: [{
|
||||
payments: {
|
||||
captures: [{
|
||||
id: "CAPTURE-1",
|
||||
status: "COMPLETED",
|
||||
amount: { currency_code: "EUR", value: "300.00" },
|
||||
seller_receivable_breakdown: {
|
||||
gross_amount: { value: "300.00" },
|
||||
paypal_fee: { value: "10.79" },
|
||||
net_amount: { value: "289.21" },
|
||||
},
|
||||
}],
|
||||
},
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
if (req.url === "/v1/notifications/verify-webhook-signature") {
|
||||
const echt = body.transmission_sig === "echte-signatur";
|
||||
return antworte(200, { verification_status: echt ? "SUCCESS" : "FAILURE" });
|
||||
}
|
||||
|
||||
antworte(404, { error: "unbekannter Pfad: " + req.url });
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1");
|
||||
await new Promise((r) => server.once("listening", r));
|
||||
const port = server.address().port;
|
||||
|
||||
/* Den nachgebauten Server unterschieben: fetch so umbiegen, dass
|
||||
Aufrufe an api-m.sandbox.paypal.com lokal landen. Sauberer als eine
|
||||
Sonderbehandlung im Produktivcode -- der bleibt dadurch unveraendert. */
|
||||
const echtesFetch = globalThis.fetch;
|
||||
globalThis.fetch = (url, opt) => {
|
||||
const u = String(url).replace(/^https:\/\/api-m\.(sandbox\.)?paypal\.com/, `http://127.0.0.1:${port}`);
|
||||
return echtesFetch(u, opt);
|
||||
};
|
||||
|
||||
process.env.PAYPAL_CLIENT_ID = "test-id";
|
||||
process.env.PAYPAL_CLIENT_SECRET = "test-secret";
|
||||
process.env.PAYPAL_WD_WEBHOOK_ID = "WH-TEST";
|
||||
delete process.env.PAYPAL_ENV; // Testmodus
|
||||
|
||||
const m = await import("./lib/webdesign-paypal.js");
|
||||
m.tokenVergessen();
|
||||
|
||||
pruefe("Testmodus ist die Voreinstellung (nicht live)", m.istLive() === false);
|
||||
pruefe("mit Zugangsdaten gilt es als eingerichtet", m.istEingerichtet() === true);
|
||||
|
||||
const bestellung = await m.bestellungAnlegen({
|
||||
zahlungId: "zahlung-abc",
|
||||
nummer: "Z-2608-0001",
|
||||
betragCent: 30000,
|
||||
zweck: "30 % Anzahlung Projekt P-2608-0001",
|
||||
rueckkehrUrl: "https://dogfather-universe.com/webdesign/portal.html?zahlung=ok",
|
||||
abbruchUrl: "https://dogfather-universe.com/webdesign/portal.html?zahlung=ab",
|
||||
});
|
||||
pruefe("Bestellnummer kommt zurueck", bestellung.orderId === "ORDER-TEST-1");
|
||||
pruefe("Freigabe-Link kommt zurueck", /checkoutnow/.test(bestellung.freigabeUrl || ""));
|
||||
pruefe("Betrag korrekt uebermittelt (300.00)", gesehen.betraege[0] === "300.00");
|
||||
pruefe("intent ist CAPTURE, nicht AUTHORIZE", gesehen.intents[0] === "CAPTURE",
|
||||
"-> nur so landet das Geld sofort auf dem Konto");
|
||||
pruefe("Doppel-Schutz-Kennung wird mitgeschickt",
|
||||
(gesehen.requestIds[0] || "").includes("zahlung-abc"));
|
||||
|
||||
const einzug = await m.bestellungEinziehen("ORDER-TEST-1");
|
||||
pruefe("Einzug meldet COMPLETED", einzug.status === "COMPLETED");
|
||||
pruefe("Einzugs-Kennung wird gelesen", einzug.captureId === "CAPTURE-1");
|
||||
pruefe("Bruttobetrag 300,00 EUR", einzug.bruttoCent === 30000);
|
||||
pruefe("Gebuehr getrennt erfasst (10,79 EUR)", einzug.gebuehrCent === 1079);
|
||||
pruefe("Nettobetrag getrennt erfasst (289,21 EUR)", einzug.nettoCent === 28921);
|
||||
pruefe("Brutto = Netto + Gebuehr geht auf", einzug.bruttoCent === einzug.nettoCent + einzug.gebuehrCent);
|
||||
|
||||
const echt = await m.webhookEchtheitPruefen(
|
||||
{ "paypal-transmission-sig": "echte-signatur", "paypal-auth-algo": "SHA256withRSA" },
|
||||
JSON.stringify({ id: "EV-1", event_type: "PAYMENT.CAPTURE.COMPLETED" })
|
||||
);
|
||||
pruefe("echter Webhook wird akzeptiert", echt === true);
|
||||
|
||||
const gefaelscht = await m.webhookEchtheitPruefen(
|
||||
{ "paypal-transmission-sig": "gefaelscht" },
|
||||
JSON.stringify({ id: "EV-2", event_type: "PAYMENT.CAPTURE.COMPLETED" })
|
||||
);
|
||||
pruefe("gefaelschter Webhook wird abgelehnt", gefaelscht === false,
|
||||
"-> sonst koennte jeder eine Zahlung vortaeuschen");
|
||||
|
||||
pruefe("bekannte Ereignisse sind zugeordnet",
|
||||
m.WEBHOOK_EREIGNISSE["PAYMENT.CAPTURE.COMPLETED"] === "zahlung_eingegangen");
|
||||
pruefe("unbekanntes Ereignis wird nicht zugeordnet",
|
||||
m.WEBHOOK_EREIGNISSE["IRGENDWAS.ANDERES"] === undefined);
|
||||
|
||||
globalThis.fetch = echtesFetch;
|
||||
server.close();
|
||||
}
|
||||
|
||||
console.log(`\n=== ${bestanden} bestanden, ${gescheitert} gescheitert ===\n`);
|
||||
process.exit(gescheitert === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user