/* ===================================================================== wiederherstellung-proben.mjs — die Probe aufs Exempel. --------------------------------------------------------------------- WARUM ES DAS GIBT Es gibt seit dem 31.08.2026 eine Sicherung ausserhalb des Servers (tools/sicherung-holen.sh, taeglich per Aufgabenplanung). Sie prueft `PRAGMA integrity_check` und dass die Personentabelle nicht leer ist. Das ist besser als nichts und beweist nicht das, worauf es ankommt. `integrity_check` sagt: die Datei ist nicht zerschossen. Es sagt NICHT: * dass die Anwendung mit dieser Datei ueberhaupt startet (eine Sicherung von vor einer Schemaaenderung kann heute an einer Wanderung scheitern, und niemand haette es gemerkt) * dass man sich danach anmelden kann * dass die Seiten Inhalte zeigen statt leerer Geruste * dass die Daten VOLLSTAENDIG sind Eine Sicherung, die man nie zurueckgespielt hat, ist eine Hoffnung. Genau das ist der Satz, den man nach einem Plattenausfall nicht mehr sagen moechte. --------------------------------------------------------------------- WAS DIESE PROBE TUT 1. nimmt die JUENGSTE Sicherung, kopiert sie in ein Wegwerf- Verzeichnis (die Sicherung selbst wird nie angefasst) 2. zaehlt die Zeilen jeder wichtigen Tabelle VOR der Wanderung 3. startet die echte Anwendung gegen die Kopie -- damit laufen alle Schemawanderungen wirklich durch 4. zaehlt danach noch einmal: keine Zeile darf verschwunden sein 5. legt in der KOPIE einen Zugang an und meldet sich damit an 6. ruft JEDE Seite auf und misst, ob wirklich Inhalt da ist 7. raeumt alles weg --------------------------------------------------------------------- DREI AUSGAENGE, NICHT ZWEI (Regel aus CLAUDE.md, 03.09.2026) 0 in Ordnung -- die Sicherung laesst sich zurueckspielen 1 NICHT in Ordnung -- hier ist wirklich etwas kaputt 2 konnte nicht nachsehen -- keine Sicherung da, Playwright fehlt. Das ist KEIN Erfolg und KEIN Fehler, und es muss anders aussehen als beides. Eine Probe, die ihre Voraussetzung nicht hat und trotzdem "ok" meldet, ist schlimmer als gar keine. --------------------------------------------------------------------- IN EIN LIVE-SYSTEM KOMMEN KEINE TESTDATEN (Regel vom 04.09.2026) Der Zugang aus Schritt 5 entsteht ausschliesslich in der WEGWERF- KOPIE unter %TEMP%. Weder der Server noch die Sicherung selbst werden beschrieben. Am Ende wird das Verzeichnis geloescht. AUFRUF: node tools/wiederherstellung-proben.mjs ===================================================================== */ import { readdirSync, statSync, copyFileSync, mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs"; import { tmpdir, homedir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; import { DatabaseSync } from "node:sqlite"; import { scryptSync, randomBytes } from "node:crypto"; const HIER = dirname(fileURLToPath(import.meta.url)); const WURZEL = join(HIER, ".."); const SICHERUNGEN = join(homedir(), "Documents", "Obelix", "Sicherungen", "workspace"); const PORT = 4471; let fehler = 0; let gemacht = 0; const ok = (b, t) => { gemacht++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; /** Der dritte Ausgang. Mit Grund UND Anleitung -- eine Meldung "geht * nicht" ohne Angabe, was zu tun waere, laesst genau den Zustand * bestehen, den sie meldet. */ function kannNichtNachsehen(grund, anleitung) { console.log("\n╔══════════════════════════════════════════════════════════"); console.log("║ KONNTE NICHT NACHSEHEN"); console.log("║ " + grund); console.log("║"); console.log("║ " + anleitung); console.log("╚══════════════════════════════════════════════════════════"); process.exit(2); } /* --------------------------------------------------------------------- 1. Die juengste Sicherung finden --------------------------------------------------------------------- */ console.log("=== Welche Sicherung wird geprobt? ==="); if (!existsSync(SICHERUNGEN)) { kannNichtNachsehen( `Das Sicherungsverzeichnis gibt es nicht: ${SICHERUNGEN}`, "Einmalig einrichten mit: powershell -ExecutionPolicy Bypass -File tools\\sicherung-einrichten.ps1"); } const kandidaten = readdirSync(SICHERUNGEN) .filter((n) => n.endsWith(".db")) .map((n) => ({ name: n, pfad: join(SICHERUNGEN, n), zeit: statSync(join(SICHERUNGEN, n)).mtimeMs })) /* Nach dem DATUM IM NAMEN, nicht nach der Dateizeit: scp setzt beim Abholen bei allen Dateien dieselbe Zeit, und dann waere "die juengste" reiner Zufall. Im Namen steht, welcher Stand es ist. */ .map((k) => ({ ...k, stand: (k.name.match(/(\d{4}-\d{2}-\d{2})/) || [])[1] || "" })) .sort((a, b) => (a.stand < b.stand ? 1 : a.stand > b.stand ? -1 : b.zeit - a.zeit)); if (!kandidaten.length) { kannNichtNachsehen( `In ${SICHERUNGEN} liegt keine einzige .db-Datei.`, "Einmal von Hand holen mit: bash tools/sicherung-holen.sh"); } const gewaehlt = kandidaten[0]; console.log(` ${kandidaten.length} Sicherungen vorhanden, geprobt wird: ${gewaehlt.name}`); console.log(` Stand ${gewaehlt.stand}, ${Math.round(statSync(gewaehlt.pfad).size / 1024)} KB`); /* WIE ALT DARF SIE SEIN? Eine Sicherung von vor drei Wochen ist im Ernstfall drei Wochen Arbeit, die weg ist. Das ist kein Fehler dieser Probe -- aber es muss dastehen. */ const tage = Math.floor((Date.now() - Date.parse(gewaehlt.stand + "T12:00:00")) / 86400000); ok(Number.isFinite(tage) && tage <= 3, `die juengste Sicherung ist ${tage} Tage alt (bis 3 in Ordnung)`); /* --------------------------------------------------------------------- 2. Kopieren -- die Sicherung selbst bleibt unberuehrt --------------------------------------------------------------------- */ const arbeit = mkdtempSync(join(tmpdir(), "wiederherstellung-")); const kopie = join(arbeit, "workspace.db"); copyFileSync(gewaehlt.pfad, kopie); const vorherGroesse = statSync(kopie).size; ok(vorherGroesse === statSync(gewaehlt.pfad).size, `die Kopie ist vollstaendig (${vorherGroesse} Byte)`); /* --------------------------------------------------------------------- 3. Zaehlen, was drin ist -- VOR der Wanderung --------------------------------------------------------------------- */ console.log("\n=== Was steht in der Sicherung? ==="); /* Die Tabellen, deren Verlust wirklich weh taete. Ein Zaehlwerk ueber ALLE Tabellen waere hier falsch: Sitzungen und Protokolle wachsen und schrumpfen von allein, ein Rueckgang dort sagt nichts. */ const WICHTIG = ["personen", "aufgaben", "termine", "protokolle", "startcheck", "bereich_punkte", "dateien", "wissen", "creator_profile", "leads"]; function zaehlen(pfad) { const d = new DatabaseSync(pfad, { readOnly: true }); const da = new Set(d.prepare( "SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name)); const raus = {}; for (const t of WICHTIG) { if (!da.has(t)) { raus[t] = null; continue; } // Tabelle gab es damals noch nicht raus[t] = d.prepare(`SELECT COUNT(*) AS n FROM "${t}"`).get().n; } raus._tabellen = da.size; d.close(); return raus; } const vorher = zaehlen(kopie); for (const t of WICHTIG) { const n = vorher[t]; console.log(` ${t.padEnd(18)} ${n === null ? "(Tabelle gab es noch nicht)" : n}`); } ok(vorher.personen > 0, `es sind ${vorher.personen} Personen drin -- ohne die kaeme niemand hinein`); ok(vorher._tabellen >= 10, `${vorher._tabellen} Tabellen sind angelegt`); /* --------------------------------------------------------------------- 4. Die Anwendung gegen die Kopie starten --------------------------------------------------------------------- */ console.log("\n=== Startet die Anwendung damit? ==="); /* NUR WORKSPACE_DB wird gesetzt -- und das genuegt. Die Anwendung leitet ihren gesamten Datenordner davon ab (`DATEN_ORDNER = dirname(DB_PFAD)` in workspace.js). Dateiablage, Bibliothek und die eigenen Sicherungen landen damit alle im Wegwerf-Verzeichnis. Es gibt bewusst KEINE eigenen Umschalter dafuer; welche zu erfinden hiesse, sich auf Variablen zu verlassen, die es nicht gibt -- sie waeren wirkungslos und man merkte es nie. */ const umgebung = { ...process.env, WORKSPACE_DB: kopie, PORT: String(PORT), SITE_ACCESS_SECRET: "wiederherstellungsprobe", SITE_PUBLIC_LAUNCH_AT: "2020-01-01T00:00:00+01:00", }; const kind = spawn(process.execPath, [join(WURZEL, "server", "index.js")], { env: umgebung, stdio: ["ignore", "pipe", "pipe"], }); let ausgabe = ""; kind.stdout.on("data", (d) => { ausgabe += d; }); kind.stderr.on("data", (d) => { ausgabe += d; }); let gestartet = false; for (let i = 0; i < 80; i++) { await new Promise((r) => setTimeout(r, 250)); if (kind.exitCode !== null) break; try { const a = await fetch(`http://127.0.0.1:${PORT}/workspace/`, { redirect: "manual" }); if (a.status < 500) { gestartet = true; break; } } catch { /* noch nicht oben */ } } ok(gestartet, gestartet ? "die Anwendung laeuft mit der zurueckgespielten Datenbank" : `die Anwendung startete NICHT (Exitcode ${kind.exitCode})`); /* Eine Wanderung, die scheitert, schreibt nach stderr und laesst die Anwendung trotzdem laufen -- deshalb wird die Ausgabe mitgelesen. */ const wanderungen = (ausgabe.match(/\[workspace\] .*(ergaenzt|angelegt|umgebaut)/g) || []); console.log(` ${wanderungen.length} Schemawanderungen liefen durch` + (wanderungen.length ? ":\n " + wanderungen.slice(0, 6).join("\n ") : "")); const schlimm = (ausgabe.match(/^.*(SQLITE_ERROR|no such column|no such table|UNIQUE constraint).*$/gm) || []); ok(schlimm.length === 0, schlimm.length ? `Datenbankfehler beim Start: ${schlimm.slice(0, 3).join(" | ")}` : "beim Start kam kein einziger Datenbankfehler"); if (!gestartet) { console.log("\n" + ausgabe.slice(-2000)); try { kind.kill(); } catch { /* schon weg */ } try { rmSync(arbeit, { recursive: true, force: true }); } catch { /* egal */ } console.log(`\n${gemacht} Pruefungen\n${fehler} FEHLER`); process.exit(1); } /* --------------------------------------------------------------------- 5. Ist nach der Wanderung noch alles da? --------------------------------------------------------------------- */ console.log("\n=== Ist nach der Wanderung noch alles da? ==="); const nachher = zaehlen(kopie); let verloren = []; for (const t of WICHTIG) { if (vorher[t] === null) continue; if (nachher[t] === null || nachher[t] < vorher[t]) { verloren.push(`${t}: ${vorher[t]} → ${nachher[t]}`); } } ok(verloren.length === 0, verloren.length ? `Zeilen sind bei der Wanderung verschwunden: ${verloren.join(", ")}` : `keine Zeile ist verlorengegangen (${WICHTIG.filter((t) => vorher[t] !== null).length} Tabellen verglichen)`); ok(nachher._tabellen >= vorher._tabellen, `${nachher._tabellen} Tabellen nach der Wanderung (vorher ${vorher._tabellen})`); /* GEGENPROBE zum Zaehlwerk: Wenn es funktioniert, muss es einen kuenstlich herbeigefuehrten Verlust auch MELDEN. Ein Zaehler, der nur "alles gut" sagen kann, sagt gar nichts. */ { const erfunden = { ...vorher, personen: vorher.personen + 5 }; const meldet = WICHTIG.some((t) => erfunden[t] !== null && nachher[t] !== null && nachher[t] < erfunden[t]); ok(meldet, "Gegenprobe: ein herbeigefuehrter Verlust WIRD erkannt"); } /* --------------------------------------------------------------------- 6. Kann man damit arbeiten? --------------------------------------------------------------------- */ console.log("\n=== Kann man sich anmelden und arbeiten? ==="); /* Der Zugang entsteht NUR in der Wegwerf-Kopie. Der echte Code steht in keiner Sicherung im Klartext (er ist gehasht), also legt die Probe sich einen eigenen an -- sonst koennte sie nur die Anmeldeseite ansehen und nichts dahinter. */ const CODE = "PROBE-" + randomBytes(6).toString("hex").toUpperCase(); { const d = new DatabaseSync(kopie); const salz = randomBytes(16).toString("hex"); const hash = scryptSync(CODE, salz, 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 ('Wiederherstellungsprobe','admin',?,?,?,1,?)`) .run(hash, salz, 32768, new Date().toISOString()); d.close(); } let pw = null; try { pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); } catch { /* nicht da */ } if (!pw) { try { kind.kill(); } catch { /* schon weg */ } try { rmSync(arbeit, { recursive: true, force: true }); } catch { /* egal */ } kannNichtNachsehen( "Playwright ist nicht auffindbar -- der Teil 'kann man damit arbeiten' konnte nicht laufen.\n" + `║ Die Datenbankproben liefen aber durch: ${gemacht} Pruefungen, ${fehler} Fehler.`, "Erwartet unter C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright"); } const browser = await pw.chromium.launch(); const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const seite = await ctx.newPage(); const BASIS = `http://127.0.0.1:${PORT}`; await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); await seite.click('.rolle[data-rolle="admin"]'); await seite.fill("#code", CODE); await seite.click("button[type=submit]"); let angemeldet = true; try { await seite.waitForURL("**/start.html", { timeout: 25000 }); } catch { angemeldet = false; } ok(angemeldet, angemeldet ? "die Anmeldung mit der zurueckgespielten Datenbank geht" : "die ANMELDUNG SCHEITERT -- die Sicherung waere im Ernstfall nutzlos"); /* GEGENPROBE: Ein falscher Code darf NICHT hineinlassen. Ohne sie bewiese die Zeile darueber nur, dass irgendetwas weiterleitet. */ { const c2 = await browser.newContext(); const s2 = await c2.newPage(); await s2.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); await s2.click('.rolle[data-rolle="admin"]'); await s2.fill("#code", "PROBE-FALSCH-FALSCH"); await s2.click("button[type=submit]"); await s2.waitForTimeout(2500); ok(!/start\.html/.test(s2.url()), `ein falscher Code kommt nicht hinein (${s2.url().split("/").pop()})`); await c2.close(); } if (angemeldet) { /* JEDE Seite, aus dem Verzeichnis gelesen -- nicht aus einer Liste von Hand. Eine neue Seite ist damit von selbst dabei. */ const seiten = readdirSync(join(WURZEL, "workspace")) .filter((n) => n.endsWith(".html") && n !== "index.html").sort(); console.log(` ${seiten.length} Seiten werden aufgerufen`); let kaputte = []; for (const s of seiten) { const konsole = []; const auf = (m) => { if (m.type() === "error") konsole.push(m.text().slice(0, 90)); }; seite.on("console", auf); const antwort = await seite.goto(BASIS + "/workspace/" + s, { waitUntil: "networkidle" }); await seite.waitForTimeout(500); /* "Die Seite laedt" reicht nicht -- ein leeres Geruest laedt auch. Gemessen wird, ob wirklich Text im Hauptbereich steht. */ const inhalt = await seite.evaluate(() => (document.querySelector("main")?.innerText || "").trim().length); seite.off("console", auf); const gut = antwort?.status() === 200 && inhalt > 40 && konsole.length === 0; if (!gut) kaputte.push(`${s} (HTTP ${antwort?.status()}, ${inhalt} Zeichen` + (konsole.length ? `, Konsole: ${konsole[0]}` : "") + ")"); } ok(seiten.length > 10 && kaputte.length === 0, kaputte.length ? `${kaputte.length} von ${seiten.length} Seiten sind kaputt:\n ${kaputte.join("\n ")}` : `alle ${seiten.length} Seiten zeigen Inhalt`); /* Und die DATEN muessen wirklich da sein, nicht nur die Geruste. Genau hier liegt der Unterschied zwischen "die Anwendung startet" und "die Sicherung ist etwas wert". */ const daten = await seite.evaluate(async () => { const holen = async (w) => { try { const a = await fetch(w, { credentials: "same-origin" }); return a.ok ? await a.json() : null; } catch { return null; } }; const p = await holen("/workspace/api/personen"); const a = await holen("/workspace/api/aufgaben"); return { personen: Array.isArray(p?.personen) ? p.personen.length : null, aufgaben: Array.isArray(a?.aufgaben) ? a.aufgaben.length : null, }; }); /* Verglichen wird gegen die AKTIVEN. Die Schnittstelle liefert nur `aktiv = 1`; gegen die Gesamtzahl aus der Sicherung zu pruefen hiesse, bei jeder je abgeschalteten Person Alarm zu schlagen -- eine Warnung, die immer kommt, ist keine mehr. Die eine Person dieser Probe zaehlt hier mit und wird abgezogen. */ let aktive = 0; { const dd = new DatabaseSync(kopie, { readOnly: true }); aktive = dd.prepare("SELECT COUNT(*) AS n FROM personen WHERE aktiv = 1").get().n - 1; dd.close(); } ok(daten.personen !== null && daten.personen - 1 === aktive, `die Anwendung liefert alle ${aktive} aktiven Personen aus ` + `(${vorher.personen} in der Sicherung, davon ${vorher.personen - aktive} abgeschaltet)`); ok(daten.aufgaben !== null, `und ${daten.aufgaben} Aufgaben`); } await ctx.close(); await browser.close(); /* --------------------------------------------------------------------- 7. Aufraeumen --------------------------------------------------------------------- */ try { kind.kill(); } catch { /* schon weg */ } await new Promise((r) => setTimeout(r, 400)); try { rmSync(arbeit, { recursive: true, force: true }); } catch { /* Windows haelt manchmal fest */ } /* Der Beleg. Ohne ihn weiss beim naechsten Mal niemand, wann zuletzt wirklich zurueckgespielt wurde -- und "wir haben eine Sicherung" ist dann wieder nur ein Satz. */ const beleg = join(SICHERUNGEN, "wiederherstellung-probe.txt"); const zeile = `${new Date().toISOString().slice(0, 16).replace("T", " ")} ` + `${fehler === 0 ? "ok " : "FEHL"} ${gewaehlt.name} ` + `${gemacht} Pruefungen, ${fehler} Fehler, ${vorher.personen} Personen\n`; try { const alt = existsSync(beleg) ? (await import("node:fs")).readFileSync(beleg, "utf8") : ""; writeFileSync(beleg, alt + zeile, "utf8"); console.log(`\nEingetragen in ${beleg}`); } catch { console.log("\nHinweis: der Beleg liess sich nicht schreiben."); } console.log(`\n${gemacht} Pruefungen`); console.log(fehler === 0 ? "DIE SICHERUNG LAESST SICH ZURUECKSPIELEN." : `${fehler} FEHLER — die Sicherung ist NICHT verlaesslich.`); process.exit(fehler ? 1 : 0);