/* Prueft die Unterweisungen mit beidseitiger Bestaetigung. Das Wichtigste steht am Ende: der VOLLZUG. Aus RunOne (01.09.2026) stammt die Lehre, dass ein Weg, den man nicht rueckgaengig machen kann, tagelang live sein und NIE gelaufen sein kann -- dort gab es null festgeschriebene Buchungen, obwohl der Umbau seit Tagen ausgeliefert war. Deshalb wird hier ausdruecklich durchgespielt: bestaetigen, abschliessen, und dann eine Aenderung VERSUCHEN. Die Ablehnung ist der eigentliche Nachweis. Und die Gegenprobe dazu: Ohne den Trigger MUSS die Aenderung durchgehen. Eine Sperre, die man nicht scheitern sehen hat, ist keine Sperre. */ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createHash } from "node:crypto"; const ordner = mkdtempSync(join(tmpdir(), "ws-schul-")); 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-schulung"); process.env.PORT = `${PORT}`; process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_ACCESS_CODE_DOGI = "DOGI-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 }); }; import { notbremse } from "./helfer-notbremse.mjs"; import { eigenerPort } from "./helfer-port.mjs"; await import("./index.js"); /* DIE NOTBREMSE. Muss NACH index.js stehen: Der Server haengt dort zwei Auffangnetze ein, die einen Absturz nur protokollieren -- richtig fuer den Betrieb, fatal fuer eine Pruefung. Ohne diese Zeile bleibt der Prozess nach einem Fehler ewig stehen, weil der Server ihn am Leben haelt (siehe helfer-notbremse.mjs). */ notbremse(480_000, "pruef-schulung"); await new Promise((r) => setTimeout(r, 700)); const BASIS = `http://127.0.0.1:${PORT}`; setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 250_000).unref?.(); /* ORTSZEIT, nicht UTC. Die Anwendung rechnet "heute" nach der Uhr des Benutzers; toISOString() liefert dagegen UTC und damit zwischen Mitternacht und 2 Uhr den VORTAG. In diesem Fenster schlug diese Pruefung fehl, ohne dass etwas kaputt war. */ const tagText = (d = new Date()) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}` + `-${String(d.getDate()).padStart(2, "0")}`; let fehler = 0; const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes } = await import("node:crypto"); for (let i = 0; i < 60; i++) { try { const p = new DatabaseSync(process.env.WORKSPACE_DB); try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } } catch { await new Promise((r) => setTimeout(r, 250)); } } const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); 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); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001"); const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001"); const idFremd = anlegen("Fremd", "creator", "CODE-FREM-0001"); d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idScout, jetzt); /* Ein echtes Dokument in der Bibliothek -- ohne Datei keine Unterweisung. */ const wOrdner = join(ordner, "wissen"); mkdirSync(wOrdner, { recursive: true }); const dateiName = randomBytes(8).toString("hex") + ".pdf"; const inhalt = "%PDF-1.4\nRegeln und Richtlinien\n%%EOF\n"; writeFileSync(join(wOrdner, dateiName), inhalt); const echterHash = createHash("sha256").update(Buffer.from(inhalt)).digest("hex"); d.prepare(`INSERT INTO wissen (titel, beschreibung, kategorie, stufe, geraet, name_datei, name_original, groesse, wichtig, veroeffentlicht, hochgeladen, erstellt_von) VALUES (?,?,?,?,?,?,?,?,0,?,?,?)`) .run("Community-Richtlinien", "Was erlaubt ist und was nicht", "tiktok-regeln", "einsteiger", "egal", dateiName, "Richtlinien.pdf", inhalt.length, tagText(), jetzt, idDogi); const wissenId = d.prepare("SELECT last_insert_rowid() AS id").get().id; d.close(); const keks = async (rolle, code) => { const a = await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle, code }) }); return (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; "); }; const kDogi = await keks("admin", "CODE-DOGI-0001"); const kScout = await keks("scout", "CODE-SCOU-0001"); const kTili = await keks("creator", "CODE-ANNA-0001"); const kFremd = await keks("creator", "CODE-FREM-0001"); const ruf = (pfad, k, o = {}) => fetch(BASIS + pfad, { ...o, headers: { Cookie: k, ...(o.headers || {}) } }); const post = (pfad, k, koerper) => ruf(pfad, k, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(koerper || {}) }); /* ---------- Anlegen -------------------------------------------------------- */ console.log("\n=== Anlegen"); let schulungId; { const a = await post("/workspace/api/schulung", kDogi, { titel: "Community-Richtlinien durchgehen", wissen_id: wissenId }); ok(a.ok, `DogFather legt eine Unterweisung an (${a.status})`); /* Ohne Dokument geht nichts -- man kann nichts bestaetigen, was man nicht gelesen hat. */ const ohne = await post("/workspace/api/schulung", kDogi, { titel: "Ohne Datei" }); ok(ohne.status === 400, `ohne Dokument wird abgelehnt (${ohne.status})`); const nichtErlaubt = await post("/workspace/api/schulung", kScout, { titel: "Von einem Scout", wissen_id: wissenId }); ok(nichtErlaubt.status === 403, `ein Scout darf keine anlegen (${nichtErlaubt.status})`); const liste = await (await ruf("/workspace/api/schulung", kTili)).json(); ok(liste.schulungen.length === 1, `sie steht in der Liste (${liste.schulungen.length})`); ok(liste.schulungen[0].datei === "Richtlinien.pdf", `mit dem Dokument (${liste.schulungen[0].datei})`); schulungId = liste.schulungen[0].id; } /* ---------- Eine Seite reicht NICHT ---------------------------------------- */ console.log("\n=== Erst beide zusammen ergeben einen Nachweis"); { const a = await post(`/workspace/api/schulung/${schulungId}/bestaetigen`, kTili, {}); const r = await a.json(); ok(a.ok, `der Creator bestaetigt (${a.status})`); ok(!!r.stand.creator_am, "sein Zeitpunkt steht"); ok(!r.stand.betreuer_am, "der der Betreuung noch nicht"); ok(r.stand.abgeschlossen === 0, `und es ist NICHT abgeschlossen (${r.stand.abgeschlossen})`); /* Zweimal bestaetigen darf nicht gehen -- sonst liesse sich ein Datum nachtraeglich verschieben. */ const nochmal = await post(`/workspace/api/schulung/${schulungId}/bestaetigen`, kTili, {}); ok(nochmal.status === 409, `zweimal bestaetigen geht nicht (${nochmal.status})`); /* Der Fingerabdruck der Datei muss festgehalten sein. */ ok(r.stand.datei_hash === echterHash, `der Fingerabdruck der Datei ist festgehalten (${r.stand.datei_hash?.slice(0, 16)}…)`); } /* ---------- Der Betreuer schliesst ab -------------------------------------- */ console.log("\n=== Abschliessen"); { const a = await post(`/workspace/api/schulung/${schulungId}/bestaetigen`, kScout, { creator_id: idTili }); const r = await a.json(); ok(a.ok, `die Betreuung bestaetigt (${a.status})`); ok(r.stand.abgeschlossen === 1, `jetzt ist es abgeschlossen (${r.stand.abgeschlossen})`); ok(!!r.stand.abgeschlossen_am, `mit Zeitpunkt (${r.stand.abgeschlossen_am})`); ok(r.stand.betreuer_id === idScout, `und dem Namen der Betreuung (${r.stand.betreuer_id})`); /* Beide IPs -- ohne die waere es eine Behauptung, kein Nachweis. */ ok(!!r.stand.creator_ip && !!r.stand.betreuer_ip, "beide Zugriffe sind vermerkt"); } /* ---------- DER VOLLZUG: es laesst sich nicht mehr aendern ---------------- */ console.log("\n=== Unveraenderlich -- und zwar wirklich"); { /* 1. Ueber die Schnittstelle. */ const nochmal = await post(`/workspace/api/schulung/${schulungId}/bestaetigen`, kTili, {}); ok(nochmal.status === 409, `erneut bestaetigen wird abgelehnt (${nochmal.status})`); const weg = await ruf(`/workspace/api/schulung/${schulungId}/bestaetigen`, kTili, { method: "DELETE" }); ok(weg.status === 409, `zuruecknehmen wird abgelehnt (${weg.status})`); /* 2. AN DER SCHNITTSTELLE VORBEI, direkt auf der Datenbank. Das ist der eigentliche Nachweis: Ein Schutz, der nur im Code steht, ist beim naechsten neuen Weg zur Datenbank wieder weg. */ const p = new DatabaseSync(process.env.WORKSPACE_DB); let aenderung = null, loeschung = null; try { p.prepare("UPDATE schulung_bestaetigt SET creator_am = '2020-01-01' WHERE schulung_id = ?") .run(schulungId); aenderung = "DURCHGEGANGEN"; } catch (f) { aenderung = f.message; } try { p.prepare("DELETE FROM schulung_bestaetigt WHERE schulung_id = ?").run(schulungId); loeschung = "DURCHGEGANGEN"; } catch (f) { loeschung = f.message; } p.close(); ok(/nicht geaendert/.test(aenderung), `die DATENBANK lehnt die Aenderung ab (${aenderung.slice(0, 60)})`); ok(/nicht geloescht/.test(loeschung), `und das Loeschen ebenfalls (${loeschung.slice(0, 60)})`); /* Und der Datensatz steht unveraendert da. */ const p2 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); const z = p2.prepare("SELECT * FROM schulung_bestaetigt WHERE schulung_id = ?").get(schulungId); p2.close(); ok(z && z.abgeschlossen === 1 && !String(z.creator_am).startsWith("2020"), `der Nachweis steht unveraendert (${z?.creator_am?.slice(0, 10)})`); } /* ---------- GEGENPROBE: ohne Trigger geht es durch ------------------------ */ console.log("\n=== Gegenprobe -- kann die Sperre ueberhaupt scheitern?"); { /* Eine Sperre, die man nicht hat scheitern sehen, ist keine Sperre. Hier wird der Trigger entfernt, dieselbe Aenderung versucht -- sie MUSS jetzt durchgehen -- und danach alles zurueckgesetzt. */ const p = new DatabaseSync(process.env.WORKSPACE_DB); p.exec("DROP TRIGGER schulung_nicht_aendern"); let ohne = null; try { p.prepare("UPDATE schulung_bestaetigt SET creator_ip = 'probe' WHERE schulung_id = ?") .run(schulungId); ohne = "durchgegangen"; } catch (f) { ohne = f.message; } /* Zurueck in den richtigen Zustand. */ p.exec(`CREATE TRIGGER schulung_nicht_aendern BEFORE UPDATE ON schulung_bestaetigt FOR EACH ROW WHEN OLD.abgeschlossen = 1 BEGIN SELECT RAISE(ABORT, 'Abgeschlossene Bestaetigung kann nicht geaendert werden'); END`); p.close(); ok(ohne === "durchgegangen", `ohne Trigger geht dieselbe Aenderung durch (${ohne}) -- die Sperre wirkt also wirklich`); } /* ---------- Wer darf was --------------------------------------------------- */ console.log("\n=== Wer darf was"); { /* Ein Creator bestaetigt nur fuer sich. */ const fremd = await post(`/workspace/api/schulung/${schulungId}/bestaetigen`, kFremd, { creator_id: idTili }); /* Der Server ignoriert creator_id bei Creatorn und nimmt die eigene -- es entsteht also ein Eintrag fuer den Fremden selbst, nicht fuer Tili. Genau das soll passieren. */ ok(fremd.ok, `ein anderer Creator legt damit nur seinen EIGENEN Stand an (${fremd.status})`); const r = await fremd.json(); ok(r.stand.creator_id === idFremd, `und zwar wirklich seinen (${r.stand.creator_id} = ${idFremd})`); /* Ein Scout nicht fuer einen fremden Creator. */ const nichtSeiner = await post(`/workspace/api/schulung/${schulungId}/bestaetigen`, kScout, { creator_id: idFremd }); ok(nichtSeiner.status === 404, `ein Scout nicht fuer einen fremden Creator (${nichtSeiner.status})`); /* Ein Creator sieht nur seinen eigenen Stand. */ const sicht = await (await ruf("/workspace/api/schulung", kTili)).json(); ok(sicht.stand.every((s) => s.creator_id === idTili), `ein Creator sieht nur seinen Stand (${sicht.stand.length} Eintraege)`); const scoutSicht = await (await ruf("/workspace/api/schulung", kScout)).json(); ok(scoutSicht.stand.every((s) => s.creator_id === idTili), `ein Scout nur den seiner Creator (${scoutSicht.stand.length})`); } /* ---------- Wird die Datei ausgetauscht, faellt es auf -------------------- */ console.log("\n=== Ausgetauschte Datei"); { writeFileSync(join(wOrdner, dateiName), "%PDF-1.4\nGANZ ANDERE REGELN\n%%EOF\n"); const a = await (await ruf("/workspace/api/schulung", kDogi)).json(); const meiner = a.stand.find((s) => s.creator_id === idTili); ok(meiner?.datei_stimmt === false, `die Seite meldet, dass die Datei nicht mehr die bestaetigte ist (${meiner?.datei_stimmt})`); /* Zurueck, damit die restliche Pruefung sauber laeuft. */ writeFileSync(join(wOrdner, dateiName), inhalt); const b = await (await ruf("/workspace/api/schulung", kDogi)).json(); ok(b.stand.find((s) => s.creator_id === idTili)?.datei_stimmt === true, "und wieder in Ordnung, wenn die Datei stimmt"); } /* ---------- Im Browser --------------------------------------------------- */ console.log("\n=== Die Seite"); const { chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await chromium.launch(); { const kontext = await browser.newContext({ viewport: { width: 1280, height: 1000 } }); const konsole = []; const seite = await kontext.newPage(); seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message)); await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); await seite.click('.rolle[data-rolle="admin"]'); await seite.fill("#code", "CODE-DOGI-0001"); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 15000 }); await seite.goto(BASIS + "/workspace/bereich.html?b=schutz", { waitUntil: "networkidle" }); await seite.waitForTimeout(1100); /* Ein Betreuer sieht den Stand EINES Creators, und die Auswahl steht auf dem ersten alphabetisch. Fuer die Pruefung wird gezielt Tili gewaehlt -- der ist der einzige mit einem abgeschlossenen Nachweis. Ein erster Anlauf hat das uebersehen und Fremds halben Stand gemessen. */ await seite.selectOption("#schulung-creator", String(idTili)); await seite.waitForTimeout(700); const r = await seite.evaluate(() => ({ da: !document.getElementById("schulung")?.hidden, anzahl: document.querySelectorAll(".unterweisung").length, stufen: [...document.querySelectorAll(".unterweisung")].map((u) => u.dataset.stufe), unterschriften: document.querySelectorAll(".unterschrift").length, gesetzt: document.querySelectorAll('.unterschrift[data-da="ja"]').length, fest: document.querySelectorAll(".unterweisung__fest").length, formular: !!document.querySelector(".schulung__neu"), ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth, })); ok(r.da, "der Unterweisungsblock steht im Bereich Schutz & Regeln"); ok(r.anzahl === 1, `eine Unterweisung (${r.anzahl})`); ok(r.stufen[0] === "fertig", `sie ist abgeschlossen (${r.stufen[0]})`); /* Beide Unterschriften sichtbar UND beide als gesetzt markiert -- ein Nachweis, bei dem nur einer unterschrieben hat, ist keiner. */ ok(r.unterschriften === 2, `beide Unterschriften stehen da (${r.unterschriften})`); ok(r.gesetzt === 2, `und beide sind gesetzt (${r.gesetzt})`); ok(r.fest === 1, "mit dem Hinweis, dass es unveraenderlich ist"); ok(r.formular, "DogFather sieht das Formular zum Hinzufuegen"); ok(r.ueber <= 0, `nichts steht ueber (${r.ueber}px)`); ok(konsole.length === 0, "keine Fehler in der Konsole" + (konsole.length ? " -> " + konsole.join(" | ") : "")); await seite.screenshot({ path: "pruef-schulung.png", fullPage: false }); await kontext.close(); } /* Ein Creator sieht denselben Block -- ohne das Formular zum Anlegen. */ { const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const seite = await kontext.newPage(); await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); await seite.click('.rolle[data-rolle="creator"]'); await seite.fill("#code", "CODE-ANNA-0001"); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 15000 }); await seite.goto(BASIS + "/workspace/bereich.html?b=schutz", { waitUntil: "networkidle" }); await seite.waitForTimeout(1000); const r = await seite.evaluate(() => ({ da: !document.getElementById("schulung")?.hidden, formular: !!document.querySelector(".schulung__neu"), wahl: !!document.getElementById("schulung-creator"), })); ok(r.da, "ein Creator sieht seine Unterweisungen"); ok(!r.formular, "aber nicht das Formular zum Anlegen"); ok(!r.wahl, "und keine Creator-Auswahl -- es geht ja nur um ihn"); await kontext.close(); } await browser.close(); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung."); process.exit(fehler ? 1 : 0);