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]>
238 lines
10 KiB
JavaScript
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);
|