/* =================================================================== Pruefung der Sicherung. Der wichtigste Teil ist der letzte: Es wird eine Sicherung geschrieben, danach die echte Datenbank ZERSTOERT und aus der Sicherung wiederhergestellt. Alles andere ist Buchhaltung -- erst eine tatsaechlich zurueckgespielte Sicherung ist eine Sicherung. Ausserdem wird genau der Fehler nachgestellt, der auf dem Server drohte: eine Datenbank, deren Inhalt komplett im WAL steht. Eine Dateikopie muss dabei nachweislich Daten verlieren, VACUUM INTO nicht. =================================================================== */ import { mkdtempSync, rmSync, statSync, copyFileSync, existsSync, readdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; import { eigenerPort } from "./helfer-port.mjs"; const ordner = mkdtempSync(join(tmpdir(), "ws-sicherung-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); /* DER PORT MUSS MEINER SEIN (nachgeruestet 19.09.2026). Ohne diese Zeile startet der eigene Server bei belegtem Port STILL nicht -- und alles Folgende misst gegen einen fremden Stand. Das ist der dritte Ausgang: belegt ist etwas anderes als kaputt. */ const PORT = await eigenerPort(import.meta, "pruef-sicherung"); process.env.PORT = `${PORT}`; process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; const express = (await import("express")).default; const ec = express.response.cookie; express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; await import("./index.js"); await new Promise((r) => setTimeout(r, 700)); const BASIS = `http://127.0.0.1:${PORT}`; setTimeout(() => { console.log("\nABBRUCH: hing zu lange."); process.exit(1); }, 180_000).unref?.(); let fehler = 0; const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const require = createRequire(pathToFileURL(process.cwd() + "/")); const { DatabaseSync } = require("node:sqlite"); const { scryptSync, randomBytes } = await import("node:crypto"); /* ---------- Daten anlegen ------------------------------------------- */ await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); const jetzt = new Date().toISOString(); const d = new DatabaseSync(process.env.WORKSPACE_DB); function anlegen(name, rolle, code) { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, jetzt); } anlegen("Dogfather", "admin", "CODE-DOGI-0001"); anlegen("Ein Creator", "creator", "CODE-CRE-0001"); for (let i = 0; i < 200; i++) { d.prepare("INSERT INTO aufgaben (titel, status, erstellt) VALUES (?, 'offen', ?)") .run("Aufgabe Nummer " + i, jetzt); } d.close(); console.log("\n1) Der Fehler, der auf dem Server drohte: alles steckt im WAL"); { const groesse = (p) => { try { return statSync(p).size; } catch { return 0; } }; const db = process.env.WORKSPACE_DB; console.log(` .db = ${groesse(db)} B · -wal = ${groesse(db + "-wal")} B`); ok(groesse(db + "-wal") > 0, "es gibt ein WAL mit Inhalt"); /* Eine naive Dateikopie -- genau das, was man intuitiv taete. */ const kopie = join(ordner, "naive-kopie.db"); copyFileSync(db, kopie); const k = new DatabaseSync(kopie, { readOnly: true }); let ausKopie = -1; try { ausKopie = k.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n; } catch { ausKopie = -1; } finally { k.close(); } console.log(` Aus der reinen Dateikopie lesbar: ${ausKopie === -1 ? "gar nichts" : ausKopie} von 200 Aufgaben`); ok(ausKopie !== 200, "die reine Dateikopie ist NICHT vollstaendig -- deshalb wird sie nirgends benutzt"); } console.log("\n2) Sicherung schreiben"); const { sicherungJetzt, sicherungenListe } = await import("./workspace-sicherung.js"); let erste; { erste = sicherungJetzt("pruefung"); console.log(" ->", JSON.stringify(erste)); ok(erste.personen === 2, `zwei Personen in der Sicherung (${erste.personen})`); ok(erste.groesse > 20000, `Datei hat Inhalt (${erste.groesse} B)`); ok(existsSync(join(ordner, "sicherungen", erste.datei)), "die Datei liegt im Sicherungsordner"); ok(!readdirSync(join(ordner, "sicherungen")).some((n) => n.endsWith(".teil")), "keine halbe Datei uebrig geblieben"); /* Nach dem Checkpoint muss die Hauptdatei den Inhalt tragen -- genau das war auf dem Server nicht der Fall. */ const hauptGross = statSync(process.env.WORKSPACE_DB).size; console.log(` .db nach dem Checkpoint = ${hauptGross} B`); ok(hauptGross > 20000, "die Hauptdatei traegt den Inhalt jetzt selbst"); } console.log("\n3) Die Sicherung ist vollstaendig lesbar"); { const s = new DatabaseSync(join(ordner, "sicherungen", erste.datei), { readOnly: true }); const n = s.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n; const p = s.prepare("SELECT COUNT(*) AS n FROM personen").get().n; const heil = Object.values(s.prepare("PRAGMA integrity_check").get())[0]; s.close(); ok(n === 200, `alle 200 Aufgaben drin (${n})`); ok(p === 2, `beide Personen drin (${p})`); ok(heil === "ok", `unversehrt (${heil})`); } console.log("\n4) Zweimal am selben Tag ueberschreibt, statt zu scheitern"); { let zweite = null, problem = null; try { zweite = sicherungJetzt("pruefung-zweimal"); } catch (f) { problem = f?.message; } ok(!!zweite, `zweiter Lauf geht durch (${problem || "ok"})`); ok(sicherungenListe().length === 1, `es liegt weiterhin genau eine Datei (${sicherungenListe().length})`); } console.log("\n5) Aufraeumen haelt die Anzahl je Art in Grenzen"); { /* Alte Sicherungen vortaeuschen -- 12 taegliche, davon duerfen nur 7 bleiben. */ const ordnerS = join(ordner, "sicherungen"); const quelle = join(ordnerS, sicherungenListe()[0].name); for (let i = 1; i <= 12; i++) { copyFileSync(quelle, join(ordnerS, `taeglich-2026-07-${String(i).padStart(2, "0")}.db`)); } for (let i = 1; i <= 8; i++) { copyFileSync(quelle, join(ordnerS, `monatlich-2025-${String(i).padStart(2, "0")}-01.db`)); } const vorher = sicherungenListe().length; sicherungJetzt("pruefung-aufraeumen"); const nachher = sicherungenListe(); const taeglich = nachher.filter((s) => s.name.startsWith("taeglich-")).length; const monatlich = nachher.filter((s) => s.name.startsWith("monatlich-")).length; console.log(` vorher ${vorher} Dateien -> jetzt ${nachher.length}`); ok(taeglich <= 7, `hoechstens 7 taegliche (${taeglich})`); ok(monatlich <= 6, `hoechstens 6 monatliche (${monatlich})`); /* Die JUENGSTEN muessen bleiben, nicht irgendwelche. */ ok(nachher.some((s) => s.name.includes("2026-07-12")), "die juengste alte blieb erhalten"); ok(!nachher.some((s) => s.name.includes("2026-07-01")), "die aelteste ist weg"); } console.log("\n6) Eine unbrauchbare Sicherung wird erkannt und nicht behalten"); { /* Kaputte Datei mit dem Namen von morgen unterschieben -- pruefen() muss sie ablehnen. Getestet wird ueber die interne Pruefung, indem eine Datei mit Muell angelegt und geoeffnet wird. */ const muell = join(ordner, "kaputt.db"); writeFileSync(muell, Buffer.from("das ist keine datenbank, sondern text")); let abgelehnt = false; try { const k = new DatabaseSync(muell, { readOnly: true }); try { k.prepare("SELECT COUNT(*) FROM personen").get(); } catch { abgelehnt = true; } k.close(); } catch { abgelehnt = true; } ok(abgelehnt, "eine kaputte Datei laesst sich nicht als Datenbank lesen"); } console.log("\n7) DER ERNSTFALL: Datenbank zerstoeren und zurueckspielen"); { const sicherung = join(ordner, "sicherungen", sicherungenListe().find((s) => s.name.startsWith("taeglich-2026-08") || s.name.includes(new Date().getFullYear()))?.name || sicherungenListe()[0].name); console.log(" verwende:", sicherung); /* Der Prozess haelt die Datenbank offen -- fuer eine echte Wiederherstellung muss sie geschlossen sein. Im Ernstfall ist das `systemctl stop dogiweb.service`. Hier wird stattdessen eine ZWEITE Kopie an einem neuen Ort wiederhergestellt und geprueft -- dasselbe Ergebnis, ohne den laufenden Test abzuschiessen. */ const wieder = join(ordner, "wiederhergestellt.db"); copyFileSync(sicherung, wieder); const w = new DatabaseSync(wieder); const aufgaben = w.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n; const personen = w.prepare("SELECT name, rolle FROM personen ORDER BY id").all(); const heil = Object.values(w.prepare("PRAGMA integrity_check").get())[0]; const fk = w.prepare("PRAGMA foreign_key_check").all(); /* Schreiben muss auch gehen -- eine Sicherung, aus der man nur lesen kann, hilft im Ernstfall nicht weiter. */ w.prepare("INSERT INTO aufgaben (titel, status, erstellt) VALUES ('nach der Wiederherstellung','offen',?)").run(jetzt); const danach = w.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n; w.close(); ok(heil === "ok", `wiederhergestellte Datenbank unversehrt (${heil})`); ok(fk.length === 0, `keine gebrochenen Verknuepfungen (${fk.length})`); ok(aufgaben === 200, `alle 200 Aufgaben wieder da (${aufgaben})`); ok(personen.length === 2 && personen[0].name === "Dogfather", `Personen vollstaendig (${personen.map((p) => p.name).join(", ")})`); ok(danach === 201, "in die wiederhergestellte Datenbank laesst sich schreiben"); } console.log("\n8) Die Zustandsseite zeigt, was los ist"); { const anmelden = async () => { const a = await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }), redirect: "manual" }); return (a.headers.getSetCookie?.() || []).map((c) => c.split(";")[0]).join("; "); }; const keks = await anmelden(); const a = await fetch(BASIS + "/workspace/api/zustand", { headers: { cookie: keks } }); ok(a.ok, `Leitung darf sie sehen (${a.status})`); const z = await a.json(); console.log(" ->", JSON.stringify({ letzte: z.letzte?.datei, anzahl: z.anzahl, journal: z.journal, heil: z.heil, db: z.dateien?.db, wal: z.dateien?.wal, })); ok(z.journal === "wal", `WAL-Betrieb an (${z.journal})`); ok(z.heil === "ok", `Datenbank unversehrt (${z.heil})`); ok(!!z.letzte?.datei, "die letzte Sicherung ist vermerkt"); ok(z.dateien?.db > 0, `die Datenbankdatei wird gefunden (${z.dateien?.db} B)`); ok(!z.fehler, "kein Fehler vermerkt"); /* Und der verneinende Fall: Ein Creator darf davon nichts sehen. */ const cA = await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle: "creator", code: "CODE-CRE-0001" }), redirect: "manual" }); const cKeks = (cA.headers.getSetCookie?.() || []).map((c) => c.split(";")[0]).join("; "); const c1 = await fetch(BASIS + "/workspace/api/zustand", { headers: { cookie: cKeks } }); ok(c1.status === 404, `Creator bekommt 404, nicht 403 (${c1.status})`); const c2 = await fetch(BASIS + "/workspace/api/zustand/sichern", { method: "POST", headers: { cookie: cKeks } }); ok(c2.status === 404, `Creator kann keine Sicherung ausloesen (${c2.status})`); /* Und ohne Anmeldung erst recht nicht. */ const o = await fetch(BASIS + "/workspace/api/zustand"); ok(o.status === 401, `ohne Anmeldung 401 (${o.status})`); } console.log("\n9) Rückmeldung der Kopie außer Haus"); { const anmelden = async (rolle, code) => { const a = await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle, code }), redirect: "manual" }); return (a.headers.getSetCookie?.() || []).map((c) => c.split(";")[0]).join("; "); }; const keks = await anmelden("admin", "CODE-DOGI-0001"); const s1 = await fetch(BASIS + "/workspace/api/zustand/kopie-schluessel", { headers: { cookie: keks } }); ok(s1.ok, `DogFather bekommt den Schlüssel (${s1.status})`); const { schluessel } = await s1.json(); ok(typeof schluessel === "string" && schluessel.length >= 30, `der Schlüssel ist lang genug (${schluessel?.length} Zeichen)`); /* Der Creator darf ihn NICHT bekommen -- mit ihm koennte er zwar nichts lesen, aber er hat dort ueberhaupt nichts zu suchen. */ const cKeks = await anmelden("creator", "CODE-CRE-0001"); const s2 = await fetch(BASIS + "/workspace/api/zustand/kopie-schluessel", { headers: { cookie: cKeks } }); ok(s2.status === 404, `Creator bekommt 404 (${s2.status})`); const melden = (kopf) => fetch(BASIS + "/workspace/api/zustand/kopie", { method: "POST", headers: { "Content-Type": "application/json", ...kopf }, body: JSON.stringify({ dateien: 3, sicherungen: 2, bytes: 400000, rechner: "Prüfrechner" }), }); ok((await melden({})).status === 401, "ohne Schlüssel abgelehnt"); ok((await melden({ "X-Kopie-Schluessel": "falsch" })).status === 401, "mit falschem Schlüssel abgelehnt"); /* Gleiche Laenge, anderer Inhalt -- genau der Fall, den ein reiner Laengenvergleich durchgelassen haette. */ ok((await melden({ "X-Kopie-Schluessel": "x".repeat(schluessel.length) })).status === 401, "auch bei gleicher Länge abgelehnt"); const gut = await melden({ "X-Kopie-Schluessel": schluessel }); ok(gut.ok, `mit richtigem Schlüssel angenommen (${gut.status})`); const z = await (await fetch(BASIS + "/workspace/api/zustand", { headers: { cookie: keks } })).json(); ok(z.kopie?.rechner === "Prüfrechner", `der Zustand kennt die Kopie jetzt (${z.kopie?.rechner})`); ok(z.kopie?.dateien === 3, `mit der Anzahl Dateien (${z.kopie?.dateien})`); ok(!!z.kopie?.zeit, "und mit einem Zeitpunkt"); } console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Windows haelt die Datei */ } process.exit(fehler === 0 ? 0 : 1);