Files
DogFatherGitandClaude Opus 5 b037867a99 Tests beenden sich jetzt sauber (process.exitCode statt process.exit)
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]>
2026-08-27 00:17:21 +02:00

244 lines
11 KiB
JavaScript

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