Files
dogfather-universe/server-internal/test-webdesign-paypal.mjs
T
DogFatherGitandClaude Opus 5 4b3ec450d5 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]>
2026-08-22 18:06:52 +02:00

238 lines
10 KiB
JavaScript

/* =====================================================================
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);