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]>
219 lines
9.4 KiB
JavaScript
219 lines
9.4 KiB
JavaScript
/* =====================================================================
|
|
test-webdesign-anfragen.mjs — Selbsttest des Anfrage-Endpunkts.
|
|
|
|
Läuft gegen eine Wegwerf-Datenbank im Arbeitsspeicher. Prüft vor allem
|
|
die Fälle, die man beim Klicken NICHT sieht: Spamschutz, Pflichtfelder,
|
|
Längenbegrenzung, und dass die Verwaltungs-Endpunkte ohne Anmeldung
|
|
wirklich dichtmachen.
|
|
|
|
Aufruf (aus server-internal/): node test-webdesign-anfragen.mjs
|
|
===================================================================== */
|
|
|
|
import { mkdtempSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
/* Eigene Datenbankdatei in einem Temporärordner: db.js liest DB_PATH beim
|
|
Import. Eine echte Datei statt ":memory:", weil better-sqlite3 mit WAL
|
|
im Arbeitsspeicher nicht arbeitet. */
|
|
process.env.DB_PATH = join(mkdtempSync(join(tmpdir(), "wd-test-")), "test.db");
|
|
|
|
const { initDb, db } = await import("./db.js");
|
|
initDb();
|
|
|
|
const { anfrageAbsenden, anfragenListe, anfrageDetail, anfrageStatus, anfrageArchivieren } =
|
|
await import("./routes/webdesign-anfragen.js");
|
|
|
|
let ok = 0, schlecht = 0;
|
|
function pruefe(name, bedingung, zusatz = "") {
|
|
if (bedingung) { ok++; console.log(" ok " + name); }
|
|
else { schlecht++; console.log(" FEHLT " + name + " " + zusatz); }
|
|
}
|
|
|
|
/* Minimaler Ersatz für Express-Request/Response. */
|
|
function antwortAttrappe() {
|
|
const a = { code: 200, daten: null };
|
|
a.status = (c) => { a.code = c; return a; };
|
|
a.json = (d) => { a.daten = d; return a; };
|
|
return a;
|
|
}
|
|
function anfrageAttrappe(koerper, { ip = "203.0.113.5", auth = null, params = {}, query = {} } = {}) {
|
|
return {
|
|
body: koerper,
|
|
params,
|
|
query,
|
|
headers: {
|
|
"cf-connecting-ip": ip,
|
|
...(auth ? { authorization: "Bearer " + auth } : {}),
|
|
},
|
|
socket: { remoteAddress: ip },
|
|
};
|
|
}
|
|
|
|
const gueltig = () => ({
|
|
name: "Testfirma Meier",
|
|
email: "[email protected]",
|
|
paket: "website",
|
|
ziel: "Wir wollen endlich online gefunden werden und Anfragen bekommen.",
|
|
datenschutz: true,
|
|
// Formular vor über einer Minute geöffnet -> Zeitprüfung ist zufrieden
|
|
geoeffnetAm: Date.now() - 60000,
|
|
});
|
|
|
|
console.log("\n=== Anfrageformular ===\n");
|
|
|
|
/* ---------- 1. Gültige Anfrage ---------- */
|
|
console.log("1. Gueltige Anfrage");
|
|
{
|
|
const res = antwortAttrappe();
|
|
await anfrageAbsenden(anfrageAttrappe(gueltig()), res);
|
|
pruefe("wird angenommen", res.daten?.ok === true, JSON.stringify(res.daten));
|
|
pruefe("Nummer im Format A-JJMM-0001", /^A-\d{4}-\d{4}$/.test(res.daten?.nummer || ""), res.daten?.nummer);
|
|
pruefe("Zusammenfassung kommt zurueck", !!res.daten?.zusammenfassung?.eingegangen);
|
|
|
|
const zeile = db.prepare(`SELECT * FROM wd_anfragen WHERE nummer = ?`).get(res.daten.nummer);
|
|
pruefe("liegt in der Datenbank", !!zeile);
|
|
pruefe("Status startet auf 'neu'", zeile?.status === "neu");
|
|
pruefe("E-Mail wird kleingeschrieben gespeichert", zeile?.email === "[email protected]");
|
|
pruefe("IP wird nur als Hash gespeichert",
|
|
!!zeile?.ip_hash && !String(zeile.ip_hash).includes("203.0.113"),
|
|
"-> die echte IP darf nirgends stehen");
|
|
pruefe("Aenderungsverlauf wurde geschrieben",
|
|
!!db.prepare(`SELECT 1 FROM wd_verlauf WHERE bereich='anfrage' AND objekt_id=?`).get(zeile.id));
|
|
}
|
|
|
|
/* ---------- 2. Pflichtfelder ---------- */
|
|
console.log("\n2. Pflichtfelder");
|
|
{
|
|
const faelle = [
|
|
["ohne Name", { name: "" }, "name"],
|
|
["ohne E-Mail", { email: "" }, "email"],
|
|
["unsinnige E-Mail", { email: "keine-email" }, "email"],
|
|
["ohne Paket", { paket: "" }, "paket"],
|
|
["unbekanntes Paket", { paket: "raumschiff" }, "paket"],
|
|
["ohne Ziel", { ziel: "" }, "ziel"],
|
|
["ohne Datenschutz-Zustimmung", { datenschutz: false }, "datenschutz"],
|
|
];
|
|
for (const [name, ueberschreibung, feld] of faelle) {
|
|
const res = antwortAttrappe();
|
|
await anfrageAbsenden(anfrageAttrappe({ ...gueltig(), ...ueberschreibung }, { ip: "203.0.113.6" }), res);
|
|
pruefe(name + " -> abgelehnt", res.code === 400 && !!res.daten?.felder?.[feld],
|
|
JSON.stringify(res.daten?.felder));
|
|
}
|
|
|
|
const res = antwortAttrappe();
|
|
await anfrageAbsenden(anfrageAttrappe({ name: "", email: "", paket: "", ziel: "", datenschutz: false }, { ip: "203.0.113.7" }), res);
|
|
pruefe("alle Fehler kommen auf einmal", Object.keys(res.daten?.felder || {}).length >= 5,
|
|
"-> sonst schickt man das Formular fuenfmal ab");
|
|
}
|
|
|
|
/* ---------- 3. Spamschutz ---------- */
|
|
console.log("\n3. Spamschutz");
|
|
{
|
|
const vorher = db.prepare(`SELECT COUNT(*) AS n FROM wd_anfragen`).get().n;
|
|
|
|
const res1 = antwortAttrappe();
|
|
await anfrageAbsenden(anfrageAttrappe({ ...gueltig(), website: "http://spam.example" }, { ip: "203.0.113.8" }), res1);
|
|
pruefe("Honigtopf: meldet Erfolg", res1.daten?.ok === true);
|
|
pruefe("Honigtopf: speichert aber NICHTS",
|
|
db.prepare(`SELECT COUNT(*) AS n FROM wd_anfragen`).get().n === vorher,
|
|
"-> absichtlich hoeflich, damit das Skript nicht umbaut");
|
|
|
|
const res2 = antwortAttrappe();
|
|
await anfrageAbsenden(anfrageAttrappe({ ...gueltig(), geoeffnetAm: Date.now() - 500 }, { ip: "203.0.113.9" }), res2);
|
|
pruefe("zu schnell ausgefuellt: speichert nichts",
|
|
db.prepare(`SELECT COUNT(*) AS n FROM wd_anfragen`).get().n === vorher);
|
|
|
|
// Mengenbegrenzung: dieselbe IP mehrfach
|
|
let letzte = null;
|
|
for (let i = 0; i < 7; i++) {
|
|
letzte = antwortAttrappe();
|
|
await anfrageAbsenden(anfrageAttrappe(gueltig(), { ip: "203.0.113.99" }), letzte);
|
|
}
|
|
pruefe("nach fuenf Anfragen pro Stunde wird gebremst", letzte.code === 429, "war " + letzte.code);
|
|
|
|
const andere = antwortAttrappe();
|
|
await anfrageAbsenden(anfrageAttrappe(gueltig(), { ip: "198.51.100.1" }), andere);
|
|
pruefe("andere Absender sind NICHT mitgesperrt", andere.daten?.ok === true);
|
|
}
|
|
|
|
/* ---------- 4. Laengen und Sonderzeichen ---------- */
|
|
console.log("\n4. Laengen und Sonderzeichen");
|
|
{
|
|
const res = antwortAttrappe();
|
|
await anfrageAbsenden(anfrageAttrappe({
|
|
...gueltig(),
|
|
name: "N".repeat(5000),
|
|
ziel: "Z".repeat(50000),
|
|
funktionen: Array.from({ length: 500 }, (_, i) => "funktion-" + i),
|
|
details: Object.fromEntries(Array.from({ length: 500 }, (_, i) => ["k" + i, "v".repeat(9000)])),
|
|
}, { ip: "198.51.100.2" }), res);
|
|
pruefe("ueberlange Eingaben werden angenommen und gekuerzt", res.daten?.ok === true);
|
|
|
|
const zeile = db.prepare(`SELECT * FROM wd_anfragen WHERE nummer = ?`).get(res.daten.nummer);
|
|
pruefe("Name auf 120 Zeichen begrenzt", zeile.name.length === 120, String(zeile.name.length));
|
|
pruefe("Ziel auf 4000 Zeichen begrenzt", zeile.ziel.length === 4000, String(zeile.ziel.length));
|
|
pruefe("Funktionsliste auf 40 Eintraege begrenzt", JSON.parse(zeile.funktionen).length === 40);
|
|
pruefe("Detailfelder auf 40 begrenzt", Object.keys(JSON.parse(zeile.details)).length === 40);
|
|
|
|
const res2 = antwortAttrappe();
|
|
await anfrageAbsenden(anfrageAttrappe({
|
|
...gueltig(),
|
|
name: "Bösewicht <script>alert(1)</script>",
|
|
ziel: "Zeile1\nZeile2\tmit Tabulator",
|
|
}, { ip: "198.51.100.3" }), res2);
|
|
const z2 = db.prepare(`SELECT * FROM wd_anfragen WHERE nummer = ?`).get(res2.daten.nummer);
|
|
pruefe("HTML wird als Text gespeichert, nicht ausgefuehrt", z2.name.includes("<script>"),
|
|
"-> Entschaerfung passiert bei der Anzeige, nicht beim Speichern");
|
|
pruefe("Zeilenumbruch und Tabulator bleiben erhalten",
|
|
z2.ziel.includes("\n") && z2.ziel.includes("\t"));
|
|
}
|
|
|
|
/* ---------- 5. Verwaltung ist ohne Anmeldung dicht ---------- */
|
|
console.log("\n5. Verwaltung ohne Anmeldung");
|
|
{
|
|
for (const [name, fn, req] of [
|
|
["Liste", anfragenListe, anfrageAttrappe(null, { query: {} })],
|
|
["Detail", anfrageDetail, anfrageAttrappe(null, { params: { id: "x" } })],
|
|
["Status aendern", anfrageStatus, anfrageAttrappe({ status: "angebot" }, { params: { id: "x" } })],
|
|
["Archivieren", anfrageArchivieren, anfrageAttrappe({}, { params: { id: "x" } })],
|
|
]) {
|
|
const res = antwortAttrappe();
|
|
await fn(req, res);
|
|
pruefe(name + " -> 401 ohne Anmeldung", res.code === 401, "war " + res.code);
|
|
pruefe(name + " -> keine Daten im Fehlerfall", !res.daten?.anfragen && !res.daten?.anfrage);
|
|
}
|
|
|
|
const res = antwortAttrappe();
|
|
await anfragenListe(anfrageAttrappe(null, { auth: "voellig-erfundenes-token" }), res);
|
|
pruefe("erfundenes Token wird abgelehnt", res.code === 401);
|
|
}
|
|
|
|
console.log(`\n=== ${ok} bestanden, ${schlecht} 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 = schlecht === 0 ? 0 : 1;
|