diff --git a/server/pruef-alle-wege.mjs b/server/pruef-alle-wege.mjs index 294dd425..65ecfb68 100644 --- a/server/pruef-alle-wege.mjs +++ b/server/pruef-alle-wege.mjs @@ -200,6 +200,49 @@ function nummerFuer(pfad) { return 1; } +/* ---------- WEGE, DIE NIE ZU ENDE ANTWORTEN ------------------------------ + + Der Chat haelt seit dem 06.09.2026 eine Verbindung offen und schickt + darueber Nachrichten, sobald sie entstehen (Server-Sent Events). Das + ist genau der Zweck -- und fuer diese Pruefung eine Falle: `a.text()` + wartet, bis der Server fertig ist, und der wird nie fertig. + + GEMESSEN, NICHT VERMUTET: Der komplette Prueflauf blieb dadurch + haengen. Angemeldet als DogFather trat er in /workspace/api/chat/strom + ein und kam nicht wieder heraus -- nach zwoelf Minuten war er immer + noch dort. Es lief kein Fehler auf, es stand kein FEHL da: Der ganze + Regressionslauf hoerte einfach auf, weiterzugehen. Das ist die + unangenehmste Sorte, weil "noch nicht fertig" und "haengt fuer immer" + von aussen gleich aussehen. + + ZWEI DINGE dagegen, und beide gehoeren zusammen: + + 1. JEDER Ruf bekommt eine Frist. Laeuft sie ab, ist das ein + ERGEBNIS ("hing") und kein Absturz -- die Pruefung laeuft weiter + und sagt am Ende, wo es klemmte. + 2. Bekannte Stroeme stehen hier mit Begruendung. Sie werden nicht + uebersprungen (ein uebersprungener Test beweist nichts), sondern + ANDERS geprueft: verbinden, den Status ablesen, abbrechen. Die + Schranke wird damit genauso gemessen wie ueberall sonst. + + Wer einen weiteren Strom baut, traegt ihn hier ein. Vergisst er es, + haengt der Lauf -- und die Frist macht daraus eine Meldung mit + Dateinamen statt eines stillen Stillstands. */ +const STROEME = new Map([ + ["/workspace/api/chat/strom", + "haelt die Verbindung offen und schickt neue Nachrichten sofort (SSE)"], +]); + +const FRIST_MS = 8000; + +/* Jeder Weg, der nicht antwortet, landet hier -- und wird am Ende + EINMAL gemeldet. Ihn an Ort und Stelle in die jeweilige Liste zu + werfen waere falsch: In Abschnitt 1 saehe er aus wie "ohne Anmeldung + erreichbar", in Abschnitt 2 fiele er ganz durch (`"hing" >= 500` ist + false, weil Zeichenkette gegen Zahl immer false ergibt). Genau so + verschwinden Befunde. */ +const HAENGER = []; + async function ruf(art, pfad, keks) { const kopf = { "Content-Type": "application/json", origin: BASIS }; if (keks) kopf.cookie = keks; @@ -210,10 +253,37 @@ async function ruf(art, pfad, keks) { start: heute, uhrzeit: "10:00", rolle: "creator", ziele: "Ziel", nachricht: "Hallo", schluessel: "technik-01", erledigt: true, }); - const a = await fetch(BASIS + pfad, { method: art, headers: kopf, body: rumpf, redirect: "manual" }); - let text = ""; - try { text = await a.text(); } catch { /* egal */ } - return { status: a.status, text }; + + const abbruch = new AbortController(); + const wecker = setTimeout(() => abbruch.abort(), FRIST_MS); + try { + const a = await fetch(BASIS + pfad, { + method: art, headers: kopf, body: rumpf, redirect: "manual", signal: abbruch.signal, + }); + /* Ein Strom liefert den Status sofort und den Rumpf nie. Der Status + ist das, worauf es hier ankommt -- also wird er gelesen und die + Verbindung danach zugemacht. */ + if (STROEME.has(pfad)) { + abbruch.abort(); + return { status: a.status, text: "(Strom, Rumpf nicht gelesen)" }; + } + let text = ""; + try { text = await a.text(); } catch { /* egal */ } + return { status: a.status, text }; + } catch (f) { + if (f?.name === "AbortError") { + /* HAENGT. Als eigener Zustand, nicht als 0 und nicht als Absturz: + Ein Weg, der nicht antwortet, ist etwas anderes als einer, der + mit einem Fehler antwortet -- und etwas ganz anderes als einer, + der in Ordnung ist. */ + HAENGER.push(`${art} ${pfad}${keks ? " (angemeldet)" : ""}`); + return { status: "hing", text: `keine Antwort binnen ${FRIST_MS} ms` }; + } + HAENGER.push(`${art} ${pfad} — ${String(f?.message || f).slice(0, 60)}`); + return { status: "fehler", text: String(f?.message || f).slice(0, 80) }; + } finally { + clearTimeout(wecker); + } } /* ======================================================================= @@ -343,6 +413,52 @@ ok(ohneSchranke.length === 0, ? `OHNE HERKUNFTSPRUEFUNG:\n ` + ohneSchranke.slice(0, 10).join("\n ") : "kein schreibender Weg nimmt eine fremde Herkunft an"); +/* ======================================================================= + 5. Hat irgendein Weg gar nicht geantwortet? + ======================================================================= */ +console.log(`\n=== Antwortet jeder Weg ueberhaupt? ===`); +ok(HAENGER.length === 0, + HAENGER.length + ? `${HAENGER.length} Aufrufe blieben ohne Antwort — das ist der Grund, wenn der ` + + `Gesamtlauf stehenbleibt:\n ` + [...new Set(HAENGER)].slice(0, 8).join("\n ") + + `\n Ist es ein Ereignisstrom, gehoert er in STROEME (oben, mit Begruendung).` + : `alle Aufrufe kamen binnen ${FRIST_MS} ms zurueck`); + +/* Und die Sonderbehandlung selbst muss stimmen: Ein Strom, der auf + einmal doch einen fertigen Rumpf liefert, ist keiner mehr -- dann + gehoert er aus der Liste heraus, sonst wird sein Inhalt nie geprueft. + Und ein Weg, den ich faelschlich als Strom eintrage, verlaere still + seine Inhaltspruefung. Deshalb wird beides nachgemessen. */ +{ + let belegt = 0; + for (const [pfad, grund] of STROEME) { + const bekannt = ROUTEN.some((r) => r.pfad === pfad); + if (!bekannt) { + ok(false, `${pfad} steht in STROEME, ist aber gar keine Route mehr — Eintrag entfernen`); + continue; + } + /* Der Strom muss WIRKLICH offen bleiben. Wenn er von selbst endet, + ist die Ausnahme unnoetig und verdeckt nur seinen Inhalt. */ + const abbruch = new AbortController(); + const wecker = setTimeout(() => abbruch.abort(), 2500); + let endeteVonSelbst = false; + let status = null; + try { + const a = await fetch(BASIS + pfad, { + headers: { cookie: KEKSE.admin }, signal: abbruch.signal, + }); + status = a.status; + await a.text(); + endeteVonSelbst = true; + } catch { /* abgebrochen = er blieb offen, wie es sein soll */ } + clearTimeout(wecker); + belegt++; + ok(status === 200 && !endeteVonSelbst, + `${pfad} liefert angemeldet ${status} und bleibt offen — ${grund}`); + } + ok(belegt === STROEME.size, `alle ${STROEME.size} eingetragenen Stroeme nachgemessen`); +} + /* ======================================================================= GEGENPROBE — findet diese Pruefung ueberhaupt etwas? ======================================================================= */ diff --git a/server/pruef-handy.mjs b/server/pruef-handy.mjs index 937f634d..43bb47b2 100644 --- a/server/pruef-handy.mjs +++ b/server/pruef-handy.mjs @@ -106,11 +106,57 @@ const { chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await chromium.launch(); -const SEITEN = [ - "start.html", "uebersicht.html", "aufgaben.html", "kalender.html", "personen.html", - "profil.html", "wissen.html", "dateien.html", "calls.html", "startcheck.html", - "report.html", "scouting.html", "automation.html", "content.html", "bereich.html?b=live", -]; +/* ---------- WELCHE SEITEN? ALLE. ----------------------------------------- + + Hier stand bis zum 06.09.2026 eine Liste von Hand -- und sie war + veraltet, ohne dass es jemandem auffiel: chat.html, leistung.html und + steckbrief.html fehlten darin, also DREI von neunzehn Seiten waren nie + auf einem Handy gemessen worden. Der Chat ausgerechnet ist die Seite, + die man am ehesten unterwegs benutzt. + + Das ist genau die Sorte Fehler, die eine Liste macht: Sie altert in + dem Moment, in dem jemand weiterarbeitet, und sie sagt nichts dazu. + Der Lauf blieb gruen, die Zahl der Pruefungen stimmte -- gemessen + wurde trotzdem zu wenig. + + Deshalb wird die Liste jetzt aus dem VERZEICHNIS gelesen. Eine neue + Seite ist damit von selbst dabei, so wie sie durch die Schranke in + workspace.js von selbst geschuetzt ist -- derselbe Gedanke: Der + sichere Rueckfall muss der VORGABEwert sein, nicht der, an den man + denken muss. + + Ausgenommen ist einzig index.html -- das ist die Anmeldeseite, sie + wird weiter unten eigens geprueft (vor der Anmeldung, denn danach + leitet sie weiter). */ +const { readdirSync } = await import("node:fs"); +const { dirname } = await import("node:path"); +const { fileURLToPath } = await import("node:url"); +/* NICHT process.cwd(): Diese Datei wird mal aus dem Wurzelverzeichnis + gestartet (tools/alles-pruefen.mjs) und mal aus server/ von Hand. Im + zweiten Fall zeigte cwd auf server/, dort gibt es kein workspace/, + und readdirSync wuerfe -- oder faende, schlimmer, ein anderes + Verzeichnis. Der Ort DIESER Datei steht dagegen fest. */ +const SEITEN_ORDNER = join(dirname(fileURLToPath(import.meta.url)), "..", "workspace"); +const ALLE_SEITEN = readdirSync(SEITEN_ORDNER) + .filter((n) => n.endsWith(".html") && n !== "index.html") + .sort(); + +/* Ein paar Seiten brauchen eine Abfrage, sonst zeigen sie nur ihre + leere Huelle. Fuer die steht hier, WOMIT sie aufgerufen werden. */ +const MIT_ABFRAGE = { "bereich.html": "?b=live" }; +const SEITEN = ALLE_SEITEN.map((n) => n + (MIT_ABFRAGE[n] || "")); + +/* Und die Zahl gehoert in die BEDINGUNG, nicht nur in den Meldetext: + Faende readdirSync nichts (falsches Arbeitsverzeichnis), liefe die + Schleife unten null Mal -- und der Lauf waere gruen, ohne eine + einzige Seite angesehen zu haben. Genau der Fehler, den ich schon + zweimal hatte. */ +if (SEITEN.length < 15) { + console.log(` FEHL nur ${SEITEN.length} Seiten in ${SEITEN_ORDNER} gefunden ` + + "-- die Handypruefung kann so nichts messen"); + process.exit(1); +} +console.log(`${SEITEN.length} Workspace-Seiten werden gemessen: ${SEITEN.join(", ")}`); /* Drei echte Geraete, nicht "irgendwas Schmales": 320 iPhone SE (1. Gen) -- das schmalste noch benutzte Geraet diff --git a/server/pruef-struktur.mjs b/server/pruef-struktur.mjs index 8bc5527e..78764659 100644 --- a/server/pruef-struktur.mjs +++ b/server/pruef-struktur.mjs @@ -398,6 +398,41 @@ console.log(` ok(ohneApple.length === 0, ohneApple.length ? `ohne apple-touch-icon (iPhone nimmt sonst einen Screenshot): ${ohneApple.join(", ")}` : `alle ${seiten.length} Workspace-Seiten nennen ein iPhone-Symbol`); + + /* UND DAS MANIFEST (06.09.2026 nachgetragen). + + Gefunden, weil ich es selbst zweimal vergessen habe: chat.html und + leistung.html -- die beiden neuesten Seiten -- hatten kein + . Folge auf dem Handy: Wer die Anwendung + installiert hat und ueber eine Benachrichtigung auf einer dieser + Seiten landet, verlaesst damit den App-Rahmen. Die Seite oeffnet + in der Adressleiste des Browsers statt in der App, mit falscher + Leistenfarbe und ohne Zurueckweg in den Rest. + + Es faellt niemandem auf, weil am Rechner alles normal aussieht. + Genau darum steht es jetzt hier: Was man beim Anlegen einer neuen + Seite vergessen kann, muss eine Pruefung nachhalten -- nicht das + Gedaechtnis. Und weil `seiten` aus dem Verzeichnis kommt, ist die + naechste neue Seite von selbst dabei. + + theme-color gehoert dazu: Ohne sie zeichnet Android die + Systemleiste weiss -- ein greller Balken ueber einer dunklen + Oberflaeche, das Gegenteil von augenschonend. */ + const ohneManifest = []; + const ohneFarbe = []; + for (const seite of seiten) { + const text = readFileSync(join(WS, seite), "utf8"); + if (!/rel="manifest"/i.test(text)) ohneManifest.push(seite); + if (!/name="theme-color"/i.test(text)) ohneFarbe.push(seite); + } + ok(seiten.length > 10 && ohneManifest.length === 0, + ohneManifest.length + ? `ohne — oeffnet auf dem Handy ausserhalb der App: ${ohneManifest.join(", ")}` + : `alle ${seiten.length} Seiten binden das Manifest ein`); + ok(seiten.length > 10 && ohneFarbe.length === 0, + ohneFarbe.length + ? `ohne theme-color — Android zeichnet die Leiste dann weiss: ${ohneFarbe.join(", ")}` + : `alle ${seiten.length} Seiten setzen eine theme-color`); } } diff --git a/tools/wiederherstellung-proben.mjs b/tools/wiederherstellung-proben.mjs new file mode 100644 index 00000000..af7b9258 --- /dev/null +++ b/tools/wiederherstellung-proben.mjs @@ -0,0 +1,406 @@ +/* ===================================================================== + 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); diff --git a/workspace/chat.html b/workspace/chat.html index d8e84e4c..3c8ac314 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -5,7 +5,8 @@