Files
dogfather-universe/tools/wiederherstellung-proben.mjs
T
DogFatherGitandClaude Opus 5 5e503463f5 Chat: die GIF-Kiste des Rudels -- und eine Luecke in der Sicherung
Filipe: "dan will ich auch dass man im chat auch sprachnachrichten und
gifts reinschicken kann. also nur die modis rechte linke hand und
dogfather."

ICH LESE "gifts" ALS GIFs -- die bewegten Bildchen -- und sage das
ausdruecklich, statt es stillschweigend anzunehmen. Im Zusammenhang
("im chat reinschicken", direkt neben Sprachnachrichten) passt nichts
anderes. Sollte er Geschenke gemeint haben, sagt er es, und dann baue
ich das.

EINE EIGENE KISTE STATT GIPHY ODER TENOR
----------------------------------------
Beide brauchen ein Konto und einen Schluessel und bekommen bei jedem
Tastendruck mit, wonach hier gesucht wird -- an eine fremde Firma, aus
einem Arbeitsplatz heraus, in dem sonst nichts nach aussen geht. Dazu
kosten sie ab einer Menge Geld. Beides steht quer zu dem, wie dieses
Haus gebaut ist.

Die Kiste laeuft auf demselben Server wie alles andere, kostet nichts
und wird mit der Zeit besser statt schlechter: Was darin liegt, hat das
Team ausgesucht. Zehn gute GIFs, die alle kennen, sind im Alltag mehr
wert als zehn Millionen fremde -- ein Katalog mit allem ist nicht mehr
Auswahl, sondern weniger.

HABEN WIR DAS SCHON? WIRD AM INHALT BEANTWORTET
-----------------------------------------------
Der Schluessel ist der SHA-256 der Datei. Ueber den NAMEN zu
vergleichen waere die naheliegende Abkuerzung -- und sie versagt genau
dort, wo Dateien "giphy.gif", "giphy(1).gif" und "download.gif"
heissen, also fast immer. Dasselbe Bild waere dreimal dieselbe Kachel.

Dasselbe GIF zweimal hineinzulegen ist deshalb KEIN Fehler: Es ist
drin, und genau das wollte man. Eine Absage waere formal richtig und im
Erleben falsch.

DAS WICHTIGSTE STUECK: ES WIRD KOPIERT, NICHT VERWIESEN
-------------------------------------------------------
Eine Nachricht zurueckzunehmen entfernt ihre Datei von der Platte. Laege
ein Kachel-GIF in demselben Ordner und mehrere Nachrichten zeigten
darauf, naehme das Zuruecknehmen EINER Nachricht das GIF fuer alle
anderen mit -- und in drei Gespraechen stuende ab da ein kaputtes Bild.
Das ist die Sorte Fehler, die erst Wochen spaeter auffaellt und dann
nicht mehr zu reparieren ist.

Deshalb liegt die Kiste in einem eigenen Ordner (chat-gifs/), und beim
Verschicken wird kopiert. Danach ist es ein ganz normaler Anhang: im
Verlauf, in der Suche, zuruecknehmbar, unter derselben Nachtruhe. Keine
zweite Sorte Nachricht mit eigenen Rechten und eigenem Loeschen.

Geprueft wird genau das: GIF hineinlegen, verschicken, aus der Kiste
nehmen -- und danach steht das verschickte immer noch im Gespraech.

"ALSO NUR" GILT AUCH HIER
-------------------------
Ansehen, hineinlegen, verschicken: alle drei nur fuer Team Dogi und
DogFather (istTeamDogi). Der Satz stand im selben Atemzug wie die
Sprachnachrichten; es gibt keinen Grund, warum er fuer das eine gelten
sollte und fuer das andere nicht. Gemessen auch am Knopf vorbei:
403/403/403.

AUGENSCHONEND -- HIER EINE ECHTE FRAGE, KEINE FORMSACHE
--------------------------------------------------------
Zwoelf gleichzeitig laufende Bildchen sind das Gegenteil von ruhig. Wer
"Bewegung reduzieren" gesetzt hat, bekommt deshalb STEHENDE Bilder: das
erste Einzelbild, im Browser auf eine Zeichenflaeche gemalt (kostet
keine zweite Datei), und es laeuft erst, wenn man es anfasst oder mit
der Tastatur darauf steht.

Fuer alle anderen laufen sie. Eine GIF-Auswahl, in der sich nichts
bewegt, ist eine, in der man nicht sieht, was man verschickt -- deshalb
steht dazu eine Gegenprobe in der Pruefung.

NEBENBEFUND, UND ER WIEGT SCHWERER ALS DIE GIFS
-----------------------------------------------
Weil die Kiste einen neuen Datenordner braucht, lief
tools/wiederherstellung-proben.mjs -- und meldete, dass der Ordner
"material" seit dem 22.09. NICHT gesichert wird. 2,4 MB
Materialbibliothek auf dem Server, nach einem Plattenausfall weg, ohne
dass die taegliche Meldung je etwas gesagt haette. Genau die Luecke,
wegen der diese Probe ueberhaupt gebaut wurde -- und sie hat sie
gefunden, weil sie die Ordner aus dem QUELLTEXT liest statt aus einer
gepflegten Liste.

Eingetragen. Danach frisch geholt und zurueckgespielt: 18 von 18 gruen,
"DIE SICHERUNG LAESST SICH ZURUECKSPIELEN" (vorher 3 Fehler).

Dabei war die Gegenprobe daneben selbst kaputt: Sie prueft, ob ein
erfundener Ordner als fehlend erkannt wird, und verlangte dafuer
`=== 1`. Das setzt voraus, dass sonst nichts fehlt. An dem Tag, an dem
wirklich etwas fehlte, wurde sie rot und zeigte damit auf sich selbst
statt auf den Fund -- zwei Meldungen, eine Ursache, und die zweite
lenkt von der ersten ab. Jetzt zaehlt sie die Differenz.

GEPRUEFT
--------
pruef-chat-anhaenge: 109 Pruefungen, 0 Fehler (vorher 86, davor 60).
pruef-chat, pruef-chat-aufloesen (126), pruef-chat-optik: gruen.
tools/wiederherstellung-proben: 18 von 18.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 02:07:56 +02:00

503 lines
23 KiB
JavaScript

/* =====================================================================
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, mkdirSync, 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)`);
/* ---------------------------------------------------------------------
2b. UND DIE DATEIEN. Eine Datenbank allein ist keine Wiederherstellung.
Beim ersten Lauf dieser Probe (06.09.2026) blieb genau eine von
achtzehn Seiten rot: der Steckbrief, mit einem 404 in der Konsole. Das
war kein Fehler der Sicherung, sondern ihre wichtigste Aussage --
das Profilbild lag NICHT in der Datenbank, sondern daneben im
Dateisystem. Wer nur die .db zurueckspielt, bekommt eine Anwendung,
die laeuft, und eine Bibliothek voller Verweise auf Dateien, die es
nicht mehr gibt.
sicherung-holen.sh holt diese Ordner mit (dateien/, wissen/). Genau
deshalb gehoeren sie auch in die Probe: Sonst beweist sie die
Wiederherstellung nur zur Haelfte -- und ausgerechnet die Haelfte,
die man im Ernstfall nicht nachbauen kann. */
function ordnerKopieren(von, nach) {
if (!existsSync(von)) return 0;
mkdirSync(nach, { recursive: true });
let n = 0;
for (const e of readdirSync(von, { withFileTypes: true })) {
const q = join(von, e.name), z = join(nach, e.name);
if (e.isDirectory()) n += ordnerKopieren(q, z);
else { copyFileSync(q, z); n++; }
}
return n;
}
/* Kopiert wird ALLES, was in der Sicherung als Ordner liegt -- nicht
eine Liste von Hand. Kommt morgen ein vierter Datenordner dazu, ist
er hier von selbst dabei. */
let dateienZahl = 0;
const gesichert = readdirSync(SICHERUNGEN, { withFileTypes: true })
.filter((e) => e.isDirectory()).map((e) => e.name);
for (const o of gesichert) {
dateienZahl += ordnerKopieren(join(SICHERUNGEN, o), join(arbeit, o));
}
ok(dateienZahl > 0,
dateienZahl ? `${dateienZahl} hochgeladene Dateien mit zurueckgespielt `
+ `(${gesichert.join(", ")})`
: "KEINE Dateien in der Sicherung -- Bilder und PDFs waeren nach einem "
+ "Plattenausfall weg, auch wenn die Datenbank vollstaendig ist");
/* ---------------------------------------------------------------------
DIE ENTSCHEIDENDE FRAGE: Sichert das Skript ALLE Ordner, die der
Server ueberhaupt benutzt?
Genau hier lag die Luecke. sicherung-holen.sh nannte dateien/ und
wissen/; die Profilbilder liegen in einem dritten Ordner und wurden
nie mitgeholt. Die Sicherung meldete trotzdem jeden Tag "in Ordnung"
-- sie hat ja nie behauptet, vollstaendig zu sein.
Eine Liste von Hand kann das nicht verhindern; sie altert genau so
still wie die, die den Fehler verursacht hat. Deshalb wird hier
NACHGEZAEHLT: Welche Ordner legt der Server an (aus dem Quelltext),
und welche holt das Skript? Was fehlt, wird gemeldet. */
{
const server = join(WURZEL, "server");
const benutzt = new Set();
for (const datei of readdirSync(server).filter((n) => /^workspace.*\.js$/.test(n))) {
const text = (await import("node:fs")).readFileSync(join(server, datei), "utf8");
for (const t of text.matchAll(/join\(DATEN_ORDNER,\s*"([^"]+)"\)/g)) benutzt.add(t[1]);
}
/* sicherungen/ ist die Sicherung selbst -- die holt das Skript als
.db-Dateien und nicht als Ordner. */
benutzt.delete("sicherungen");
const skript = (await import("node:fs")).readFileSync(join(HIER, "sicherung-holen.sh"), "utf8");
const geholt = (skript.match(/^ORDNER="([^"]+)"/m) || [])[1]?.split(/\s+/) || [];
ok(benutzt.size > 0, `der Server benutzt ${benutzt.size} Datenordner: ${[...benutzt].join(", ")}`);
const fehlend = [...benutzt].filter((o) => !geholt.includes(o));
ok(fehlend.length === 0,
fehlend.length
? `NICHT GESICHERT: ${fehlend.join(", ")} -- diese Ordner legt der Server an, `
+ `tools/sicherung-holen.sh holt sie aber nicht. Nach einem Plattenausfall `
+ `waeren sie weg, ohne dass die taegliche Meldung je etwas gesagt haette.`
: `alle ${benutzt.size} holt tools/sicherung-holen.sh (${geholt.join(", ")})`);
/* GEGENPROBE: Erkennt der Vergleich einen fehlenden Ordner ueberhaupt?
Ohne sie bewiese "nichts fehlt" nur, dass die Suche nichts findet.
GEZAEHLT WIRD DIE DIFFERENZ, NICHT DIE SUMME (berichtigt am
23.09.2026). Hier stand `=== 1` -- und das setzte voraus, dass
sonst nichts fehlt. An dem Tag, an dem wirklich etwas fehlte
(material), wurde die Gegenprobe rot und zeigte damit auf sich
selbst statt auf den Fund: zwei Meldungen, eine Ursache, und die
zweite lenkt von der ersten ab.
Eine Gegenprobe soll beweisen, dass der Vergleich REAGIERT. Das
tut sie, wenn ein zusaetzlicher Ordner die Zahl um genau eins
erhoeht -- unabhaengig davon, wie sie vorher stand. */
const fehlendJetzt = fehlend.length;
ok([...benutzt, "erfundenerordner"].filter((o) => !geholt.includes(o)).length
=== fehlendJetzt + 1,
`Gegenprobe: ein erfundener Ordner WIRD als fehlend erkannt (${fehlendJetzt} -> ${fehlendJetzt + 1})`);
}
/* ---------------------------------------------------------------------
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);