/* ===================================================================== Prueft die GANZE Kette, nicht nur die Rechenschritte. test-webpush.mjs beweist, dass die Verschluesselung der Norm entspricht. Das ist die halbe Miete -- sagt aber nichts darueber, ob im Betrieb tatsaechlich etwas ankommt. Dazwischen liegen die Stellen, an denen es erfahrungsgemaess scheitert: falsche Kopfzeilen, ein Geraet, das nach einem Fehler ewig weiter angefunkt wird, oder eine Meldung, die Kundendaten enthaelt, obwohl sie das nicht soll. Deshalb laeuft hier ein echter Empfaenger mit: ein kleiner Server, der sich verhaelt wie ein Push-Dienst, die Nachricht ENTSCHLUESSELT und nachsieht, was drinsteht. ===================================================================== */ import http from "http"; import crypto from "crypto"; import fs from "fs"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const TEST_DB = join(__dirname, "test-push.db"); /* Vor dem Import von db.js setzen -- danach ist der Pfad festgelegt. */ fs.rmSync(TEST_DB, { force: true }); fs.rmSync(TEST_DB + "-wal", { force: true }); fs.rmSync(TEST_DB + "-shm", { force: true }); process.env.DB_PATH = TEST_DB; process.env.ENCRYPTION_KEY = Buffer.from(crypto.randomBytes(32)).toString("base64"); const { db, initDb } = await import("./db.js"); initDb(); const P = await import("./lib/webdesign-push.js"); const { ausB64u, b64u } = await import("./lib/webpush.js"); let ok = 0, fehl = 0; const pruefe = (name, gut, info) => { if (gut) { ok++; console.log(" ok " + name + (info ? " -> " + info : "")); } else { fehl++; console.log(" FEHL " + name + (info ? " -> " + info : "")); } }; /* --- Ein Empfaenger, der wirklich entschluesselt -------------------- */ const empfaenger = crypto.createECDH("prime256v1"); empfaenger.generateKeys(); const authGeheimnis = crypto.randomBytes(16); let empfangen = []; let antwortStatus = 201; const dienst = http.createServer((q, r) => { const teile = []; q.on("data", (d) => teile.push(d)); q.on("end", () => { const koerper = Buffer.concat(teile); let klartext = null; try { const salt = koerper.subarray(0, 16); const asPub = koerper.subarray(21, 86); const inhalt = koerper.subarray(86); const gemeinsam = empfaenger.computeSecret(asPub); const ikm = Buffer.from(crypto.hkdfSync("sha256", gemeinsam, authGeheimnis, Buffer.concat([Buffer.from("WebPush: info\0"), empfaenger.getPublicKey(), asPub]), 32)); const cek = Buffer.from(crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: aes128gcm\0"), 16)); const nonce = Buffer.from(crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: nonce\0"), 12)); const d = crypto.createDecipheriv("aes-128-gcm", cek, nonce); d.setAuthTag(inhalt.subarray(inhalt.length - 16)); const roh = Buffer.concat([d.update(inhalt.subarray(0, inhalt.length - 16)), d.final()]); klartext = roh.subarray(0, roh.length - 1).toString("utf8"); } catch (e) { klartext = "ENTSCHLUESSELN FEHLGESCHLAGEN: " + e.message; } empfangen.push({ kopf: q.headers, klartext, laenge: koerper.length }); r.writeHead(antwortStatus); r.end(""); }); }); await new Promise((f) => dienst.listen(4407, f)); const ENDPUNKT = "http://127.0.0.1:4407/push/geraet-1"; console.log("1. SCHLUESSEL ENTSTEHEN BEIM ERSTEN START"); const e1 = await P.vapidSicherstellen(); pruefe("beim ersten Mal neu erzeugt", e1.zustand === "neu", e1.zustand); const schluessel1 = P.oeffentlicherSchluessel(); pruefe("oeffentlicher Schluessel ist da", !!schluessel1 && ausB64u(schluessel1).length === 65); /* Der wichtigste Test dieser Datei. Wechselt der oeffentliche Schluessel, sind schlagartig ALLE Anmeldungen wertlos -- und niemand merkt, warum nichts mehr ankommt. */ const e2 = await P.vapidSicherstellen(); pruefe("beim zweiten Start NICHT neu erzeugt", e2.zustand === "vorhanden", e2.zustand); pruefe("Schluessel ist unveraendert", P.oeffentlicherSchluessel() === schluessel1); console.log("\n2. GERAET ANMELDEN"); P.geraetAnmelden({ endpoint: ENDPUNKT, keys: { p256dh: b64u(empfaenger.getPublicKey()), auth: b64u(authGeheimnis) }, }, "dogfather", "Chrome auf Android"); pruefe("ein Geraet in der Liste", P.geraeteListe(null).length === 1); /* Zweimal dasselbe Geraet darf nicht zwei Eintraege ergeben -- sonst kaeme jede Meldung doppelt. */ P.geraetAnmelden({ endpoint: ENDPUNKT, keys: { p256dh: b64u(empfaenger.getPublicKey()), auth: b64u(authGeheimnis) }, }, "dogfather", "Chrome auf Android"); pruefe("erneute Anmeldung ergibt keinen zweiten Eintrag", P.geraeteListe(null).length === 1, P.geraeteListe(null).length + " Eintrag/Eintraege"); pruefe("Adresse wird NICHT herausgegeben", !JSON.stringify(P.geraeteListe(null)).includes("4407")); pruefe("Dienst wird lesbar benannt", P.geraeteListe(null)[0].dienst === "127.0.0.1", P.geraeteListe(null)[0].dienst); console.log("\n3. EINE MELDUNG GEHT RAUS UND KOMMT LESBAR AN"); empfangen = []; const r1 = await P.meldeAllen({ titel: "Neue Anfrage", text: "A-2608-0009 (shop)", url: "/webdesign/verwaltung.html", art: "anfrage", }); pruefe("als gesendet gemeldet", r1.gesendet === 1, JSON.stringify(r1)); pruefe("genau eine Nachricht angekommen", empfangen.length === 1); const n = empfangen[0]; pruefe("Kopfzeile Content-Encoding", n.kopf["content-encoding"] === "aes128gcm", n.kopf["content-encoding"]); pruefe("Kopfzeile Content-Type", n.kopf["content-type"] === "application/octet-stream"); pruefe("TTL gesetzt", !!n.kopf.ttl, n.kopf.ttl + " Sekunden"); pruefe("VAPID-Ausweis mitgeschickt", /^vapid t=.+, k=.+$/.test(n.kopf.authorization || "")); let inhalt = {}; try { inhalt = JSON.parse(n.klartext); } catch (e) { /* faellt unten auf */ } pruefe("Nachricht liess sich entschluesseln", !!inhalt.titel, n.klartext?.slice(0, 60)); pruefe("Titel stimmt", inhalt.titel === "Neue Anfrage", inhalt.titel); pruefe("Text stimmt", inhalt.text === "A-2608-0009 (shop)", inhalt.text); /* Datenschutz: Auf einem Sperrbildschirm haben Kundendaten nichts verloren. Der Test prueft es an der Stelle, an der es zaehlt -- im entschluesselten Text, nicht im Quelltext. */ console.log("\n4. KEINE KUNDENDATEN IN DER MELDUNG"); const alles = (n.klartext || "").toLowerCase(); for (const verboten of ["@", "telefon", "gmail", "budget"]) { pruefe(`enthaelt kein "${verboten}"`, !alles.includes(verboten)); } console.log("\n5. EIN ABGEMELDETES GERAET WIRD ENTFERNT"); antwortStatus = 410; const r2 = await P.meldeAllen({ titel: "x", text: "y" }); pruefe("als abgemeldet erkannt", r2.entfernt === 1, JSON.stringify(r2)); pruefe("Eintrag ist weg", P.geraeteListe(null).length === 0); console.log("\n6. EIN VORUEBERGEHENDER FEHLER LOESCHT NICHTS"); P.geraetAnmelden({ endpoint: ENDPUNKT, keys: { p256dh: b64u(empfaenger.getPublicKey()), auth: b64u(authGeheimnis) }, }, "dogfather", "Chrome auf Android"); antwortStatus = 500; const r3 = await P.meldeAllen({ titel: "x", text: "y" }); pruefe("als Fehler gezaehlt", r3.fehler === 1, JSON.stringify(r3)); /* Der entscheidende Unterschied: Bei 500 ist der Dienst gestoert, nicht das Geraet weg. Loeschen waere hier falsch -- eine einzelne Stoerung wuerde die Anmeldung kosten. */ pruefe("Geraet bleibt erhalten", P.geraeteListe(null).length === 1); pruefe("Fehler wird vermerkt", P.geraeteListe(null)[0].fehler === 1, "Zaehler " + P.geraeteListe(null)[0].fehler); antwortStatus = 201; await P.meldeAllen({ titel: "x", text: "y" }); pruefe("nach Erfolg ist der Zaehler wieder 0", P.geraeteListe(null)[0].fehler === 0); console.log("\n7. DIE ANZEIGE SAGT DIE WAHRHEIT"); const st = P.stand(); pruefe("meldet eingerichtet", st.eingerichtet === true); pruefe("meldet, dass eine Meldung wirkt", st.wirktMeldung === true); P.geraetAbmelden(ENDPUNKT); pruefe("ohne Geraet wirkt keine Meldung mehr", P.stand().wirktMeldung === false); dienst.close(); /* ⚠️ KEIN db.close() vor process.exit() (geaendert 26.08.2026). Auf dem Server brach dieser Test genau hier ab: node::RemoveEnvironmentCleanupHook … Assertion failed: (env) != nullptr Statement::~Statement() … better_sqlite3.node Abgebrochen better-sqlite3 raeumt seine Statements ueber einen Aufraeumhaken ab. Wird die Verbindung unmittelbar vor dem Prozessende geschlossen, laeuft dieser Haken ins Leere -- Node bricht mit einer Assertion ab, und zwar NACH allen Pruefungen, aber VOR der Zusammenfassung. Die Folge war schlimmer als der Absturz selbst: Der Test lieferte einen Fehlercode, 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. Man sucht dann den Fehler bei den Endpunkten statt beim Aufraeumen einer Testdatenbank. Node schliesst die Verbindung beim Beenden ohnehin. Die Testdateien werden vorher entfernt; bleibt unter Windows eine gesperrte Datei zurueck, faengt das rmSync mit force ab. */ // (db.close() entfernt) /* Aufraeumen darf nicht ueber den Testausgang entscheiden. Ohne db.close() (siehe oben) haelt der Prozess die Datei noch offen. Unter Windows scheitert das Loeschen dann mit EBUSY -- und "force" hilft dagegen nicht, es unterdrueckt nur "Datei nicht gefunden". Ein Test, der an seinem eigenen Aufraeumen scheitert, meldet einen Fehler, den es fachlich nicht gibt. Beim naechsten Start wird die Datei ohnehin neu angelegt. */ for (const e of ["", "-wal", "-shm"]) { try { fs.rmSync(TEST_DB + e, { force: true }); } catch { /* beim naechsten Lauf */ } } console.log(`\n===== ${ok} bestanden, ${fehl} fehlgeschlagen =====`); /* ⚠️ 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 = fehl ? 1 : 0;