Der Absturz auf dem Server bestand nach dem ersten Fix fort: node::RemoveEnvironmentCleanupHook … Assertion failed: (env) != nullptr Statement::~Statement() … better_sqlite3.node Abgebrochen DER ERSTE VERSUCH GING AN DER URSACHE VORBEI Ich hatte db.close() entfernt -- naheliegend, weil der Aufrufverlauf auf einen Statement-Destruktor zeigte. Es half nicht. Die Ursache liegt eine Ebene tiefer: process.exit() beendet Node SOFORT, waehrend better-sqlite3 noch offene Statements haelt. Deren Aufraeumhaken laeuft dann ins Leere. process.exitCode setzt nur den Rueckgabewert; Node beendet sich danach von selbst, sobald nichts mehr aussteht -- und raeumt dabei in der richtigen Reihenfolge auf. WARUM DAS MEHR ALS EIN SCHOENHEITSFEHLER WAR Der Absturz kam NACH allen Pruefungen und VOR der Zusammenfassung. Der Test meldete einen Fehler, obwohl inhaltlich alles bestanden war. In einer mit && verketteten Befehlsfolge blieb deshalb der anschliessende Dienst-Neustart aus, und die neuen Endpunkte antworteten weiter mit 404. Gesucht habe ich bei den Endpunkten, beim Deploy, an der Zugangswand -- die Ursache lag beim Beenden eines Testprozesses. BEMERKENSWERT Fuenf Tests im Projekt benutzten process.exitCode bereits. Das Muster war also etabliert; meine neuen Dateien wichen davon ab, ohne dass es jemandem auffiel. Sechs Tests sind jetzt angeglichen, alle geprueft: Rueckgabewert 0, Zusammenfassung vollstaendig. test-push-kette 30, test-altabbruch 16, test-webdesign-anfragen 37, test-webdesign-portal 49, test-webdesign-paypal 28, test-personendaten 15 Co-Authored-By: Claude Opus 5 <[email protected]>
263 lines
11 KiB
JavaScript
263 lines
11 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.exitCode STATT process.exit() (geaendert 27.08.2026).
|
|
|
|
Auf dem Server brach dieser Test reproduzierbar ab:
|
|
|
|
node::RemoveEnvironmentCleanupHook … Assertion failed: (env) != nullptr
|
|
Statement::~Statement() … better_sqlite3.node
|
|
Abgebrochen
|
|
|
|
Ein erster Versuch entfernte db.close() -- das half NICHT. Die
|
|
Ursache liegt eine Ebene tiefer: process.exit() beendet Node sofort,
|
|
waehrend better-sqlite3 noch offene Statements haelt. Deren
|
|
Aufraeumhaken laeuft dann ins Leere.
|
|
|
|
process.exitCode setzt nur den Rueckgabewert; Node beendet sich
|
|
danach von selbst, sobald nichts mehr aussteht -- und raeumt dabei in
|
|
der richtigen Reihenfolge auf.
|
|
|
|
WARUM DAS MEHR ALS EIN SCHOENHEITSFEHLER WAR
|
|
|
|
Der Absturz kam NACH allen Pruefungen und VOR der Zusammenfassung.
|
|
Der Test meldete also einen Fehler, obwohl inhaltlich alles bestanden
|
|
war. In einer mit && verketteten Befehlsfolge blieb deshalb der
|
|
anschliessende Dienst-Neustart aus, und neue Endpunkte antworteten
|
|
weiter mit 404. Man sucht dann bei den Endpunkten -- die Ursache lag
|
|
beim Beenden eines Testprozesses. */
|
|
process.exitCode = gescheitert === 0 ? 0 : 1;
|