/* ===================================================================== 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: "kunde@example.com" }, 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);