/* ===================================================================== DIE RECHTETAFEL — die Pruefung, die aus einer Absicht eine Garantie macht (11.09.2026) Filipe: "egal welche rolle oder person hinzugefuegt wird soll immer nur das sehen wass ich erlaube. mehr nicht. soll nichts so sein dass wenn mann eine rolle oder jemanden hinzufuegt dass er dan alles sieht." --------------------------------------------------------------------- WARUM DIESE DATEI UND NICHT EIN KOMMENTAR In `rechte.js` stand bis heute ein Satz, der genau das Richtige verlangte: *"Wer eine Rolle hinzufuegt, muss diese Liste durchgehen."* Er stand seit dem 10.09. da, und am Tag darauf standen immer noch neun Seiten auf `null`. Ein Satz, der sich auf ein Gedaechtnis verlaesst, ist keine Sicherung. Diese Datei verlaesst sich auf nichts. Sie wird rot, wenn * eine Rolle keinen Eintrag hat, * eine Seite keinen Eintrag hat, * eine unbekannte Rolle irgendwo hineinkaeme, * oder eine unbekannte Seite sich fuer irgendwen oeffnet. Man KANN es damit nicht mehr vergessen -- der Prueflauf laesst es nicht zu. --------------------------------------------------------------------- DREI EBENEN, UND JEDE BEANTWORTET ETWAS ANDERES 1. DIE TAFEL SELBST, ohne Server. Vollstaendig, schnell, und sie vergleicht gegen das DATEISYSTEM statt gegen eine zweite Liste -- eine zweite Liste waere die Stelle, an der beide auseinanderlaufen. 2. DER SERVER. Dass die Tafel richtig ist, heisst nicht, dass sie auch gefragt wird. Genau dieser Unterschied hat am 10.09. drei Stunden gekostet: Kachel und Schnittstelle stimmten, die Seite warf trotzdem zurueck. Hier wird deshalb wirklich angeklopft. 3. DIE GEGENPROBE. Eine Pruefung, die immer bestaetigt, bestaetigt nichts. Am Ende bekommt sie absichtlich eine kaputte Tafel und MUSS sie als kaputt melden. ===================================================================== */ import { mkdtempSync, rmSync, readdirSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { eigenerPort } from "./helfer-port.mjs"; const PORT = await eigenerPort(import.meta, "pruef-rechtetafel"); const ordner = mkdtempSync(join(tmpdir(), "ws-rechte-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = String(PORT); process.env.SITE_ACCESS_SECRET = "lokaler-test"; 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"; await import("./index.js"); notbremse(180_000, "pruef-rechtetafel"); await new Promise((r) => setTimeout(r, 700)); const BASIS = `http://127.0.0.1:${PORT}`; /* EIGENE ZUHOERER. index.js haengt fuer den Betrieb Auffangnetze an `uncaughtException` -- eine Pruefung, die dieselbe Datei in denselben Prozess laedt, erbt sie und wuerde nach einem Absturz weiterhaengen, statt rot zu werden. */ process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); let fehler = 0, geprueft = 0; const melde = (t) => console.log(t); const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const rechte = await import("./rechte.js"); const w = await import("./workspace.js"); /* ======================================================================= 1. DIE TAFEL IST VOLLSTAENDIG ======================================================================= */ melde(""); melde("=== 1. Jede Rolle und jede Seite hat einen Eintrag ==="); /* DIE ROLLENLISTE DARF NICHT ZWEIMAL EXISTIEREN. rechte.js kennt ALLE_ROLLEN, workspace.js kennt ROLLEN. Zwei Listen derselben Sache laufen auseinander -- die Frage ist nur wann. Hier werden sie gegeneinandergehalten, damit das Wann "beim naechsten Prueflauf" ist und nicht "wenn jemand hineinfaellt". */ { const ausWorkspace = [...(w.ROLLEN_NAME ? Object.keys(w.ROLLEN_NAME) : [])].sort(); const ausTafel = [...rechte.ALLE_ROLLEN].sort(); ok(ausWorkspace.length > 0, `workspace.js kennt ${ausWorkspace.length} Rollen`); ok(JSON.stringify(ausWorkspace) === JSON.stringify(ausTafel), `beide Listen sind identisch (${ausTafel.join(", ")})`); } /* JEDE DATEI AUF DER PLATTE BRAUCHT EINEN EINTRAG. Verglichen wird gegen das Dateisystem, nicht gegen eine Liste im Code. Eine neue Seite entsteht als DATEI -- wer sie anlegt und den Eintrag vergisst, faellt genau hier auf. */ const SEITEN_ORDNER = new URL("../workspace/", import.meta.url).pathname .replace(/^\/([A-Za-z]:)/, "$1"); const DATEIEN = readdirSync(SEITEN_ORDNER) .filter((f) => f.endsWith(".html")) .map((f) => "/workspace/" + f).sort(); { const dateien = DATEIEN; const inTafel = new Set([...rechte.alleSeiten(), ...rechte.OHNE_ANMELDUNG]); const fehlend = dateien.filter((p) => !inTafel.has(p)); const ueberzaehlig = rechte.alleSeiten().filter((p) => !dateien.includes(p)); ok(dateien.length >= 20, `${dateien.length} Seiten liegen im Ordner`); ok(fehlend.length === 0, `jede davon steht in der Tafel${fehlend.length ? " -- FEHLT: " + fehlend.join(", ") : ""}`); ok(ueberzaehlig.length === 0, `und jeder Eintrag hat eine Datei${ueberzaehlig.length ? " -- OHNE DATEI: " + ueberzaehlig.join(", ") : ""}`); } /* KEIN EINTRAG DARF UNSCHARF SEIN. `null` war der alte Fehler; eine Rolle, die es gar nicht gibt, waere der naechste. */ { const bekannt = new Set(rechte.ALLE_ROLLEN); const kaputt = []; for (const [pfad, rollen] of Object.entries(rechte.SEITEN)) { if (!Array.isArray(rollen)) { kaputt.push(`${pfad}: kein Feld`); continue; } for (const r of rollen) if (!bekannt.has(r)) kaputt.push(`${pfad}: unbekannte Rolle "${r}"`); } ok(kaputt.length === 0, `alle Eintraege sind Listen bekannter Rollen${kaputt.length ? " -- " + kaputt.join(" | ") : ""}`); } /* ======================================================================= 2. WAS NICHT IN DER TAFEL STEHT, IST VERBOTEN ======================================================================= */ melde(""); melde("=== 2. Die Regel selbst ==="); const PHANTOM = "xx-gibt-es-nicht"; { const alle = rechte.alleSeiten(); const durchgelassen = alle.filter((p) => rechte.darfSeite({ rolle: PHANTOM }, p)); /* KEINE FESTE ZAHL MEHR (11.09.2026). Hier stand `alle.length === 20`. Das war richtig an dem Tag, an dem es geschrieben wurde, und wurde bei der naechsten neuen Seite rot -- ohne dass irgendetwas kaputt war. Eine feste Schwelle ist eine Rechnung von gestern; genau deshalb steht sie in diesem Haus in der Liste der teuren Fehler. Gemessen wird jetzt, was die Zeile eigentlich wissen will: Die Tafel deckt die Dateien ab, die es WIRKLICH gibt -- jede ausgelieferte Seite genau einmal, die Zugangswaende ausgenommen (die stehen in OHNE_ANMELDUNG, weil sich dort noch niemand angemeldet hat). UND SIE DARF NICHT LEER SEIN: `alle.length > 0` gehoert in die BEDINGUNG, nicht nur in den Meldetext. Sonst waere die Zeile bei einer leeren Tafel und einem leeren Ordner gruen -- der gruene Haken ueber der Null. */ const erwartet = DATEIEN.length - rechte.OHNE_ANMELDUNG.length; ok(alle.length > 0 && alle.length === erwartet, `${alle.length} Seiten in der Tafel (${DATEIEN.length} Dateien minus ${rechte.OHNE_ANMELDUNG.length} Waende)`); ok(durchgelassen.length === 0, `eine unbekannte Rolle kommt auf KEINE davon${durchgelassen.length ? " -- durchgelassen: " + durchgelassen.join(", ") : ""}`); /* DIE GEGENPROBE ZUR OBIGEN ZEILE. Ohne sie waere sie auch dann gruen, wenn darfSeite() schlicht immer nein sagt -- und dann waere das ganze Haus zu, ohne dass diese Datei es meldet. */ const echteWege = rechte.ALLE_ROLLEN .map((r) => [r, alle.filter((p) => rechte.darfSeite({ rolle: r }, p)).length]); const ohneSeite = echteWege.filter(([, n]) => n === 0).map(([r]) => r); ok(ohneSeite.length === 0, `und jede ECHTE Rolle kommt irgendwo hin (${echteWege.map(([r, n]) => r + ":" + n).join(" ")})`); } { /* EINE SEITE, DIE ES NICHT GIBT. Das war das zweite Loch: `undefined` fiel durch dieselbe Bedingung wie `null`. */ const erfunden = rechte.ALLE_ROLLEN .filter((r) => rechte.darfSeite({ rolle: r }, "/workspace/gibt-es-nicht.html")); ok(erfunden.length === 0, `eine Seite ohne Eintrag oeffnet sich fuer niemanden -- auch nicht fuer DogFather`); ok(rechte.darfSeite(null, "/workspace/start.html") === false, "und ohne Anmeldung erst recht nicht"); } /* ======================================================================= 3. DIE TAFEL WIRD AUCH WIRKLICH GEFRAGT ======================================================================= */ melde(""); melde("=== 3. Der Server haelt sich daran ==="); const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); const schluessel = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", schluessel, jetzt); 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, code_kennung, aktiv, erstellt)" + " VALUES (?,?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt); } anlegen("Filipe", "admin", "CODE-DOGI-0001"); anlegen("Mara", "manager", "CODE-MANA-0001"); d.close(); async function anmelden(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((c) => c.split(";")[0]).join("; "); } const kDogi = await anmelden("admin", "CODE-DOGI-0001"); const kMana = await anmelden("manager", "CODE-MANA-0001"); async function hole(pfad, keks) { const a = await fetch(BASIS + pfad, { headers: { cookie: keks }, redirect: "manual" }); return { status: a.status, ziel: a.headers.get("location") || "" }; } ok(kDogi.length > 0 && kMana.length > 0, "beide Anmeldungen haben eine Sitzung"); { const a = await hole("/workspace/automation.html", kDogi); ok(a.status === 200, `DogFather kommt auf automation.html (${a.status})`); const b = await hole("/workspace/automation.html", kMana); ok(b.status === 302 && b.ziel.endsWith("start.html"), `eine Managerin nicht -- sie landet auf der Startseite (${b.status} ${b.ziel})`); } { /* DER EIGENTLICHE NEUE FALL: eine Seite, die es in der Tafel nicht gibt. Vorher fiel sie durch die Schranke und traf danach auf express.static -- also 404, was von aussen aussieht wie "gibt es nicht", obwohl sie in Wahrheit einfach nicht geschuetzt war. Jetzt greift die Schranke davor. */ const a = await hole("/workspace/gibt-es-nicht.html", kDogi); ok(a.status === 302 && a.ziel.endsWith("start.html"), `eine Seite ohne Eintrag wird abgewiesen, nicht ausgeliefert (${a.status} ${a.ziel})`); } { /* Und die Gegenprobe: Die geoeffneten Seiten oeffnen sich wirklich. Sonst waere "alles abgewiesen" ebenfalls gruen. */ let auf = 0; for (const p of rechte.seitenFuer("admin")) { const a = await hole(p, kDogi); if (a.status === 200) auf++; } ok(auf === rechte.seitenFuer("admin").length, `alle ${auf} Seiten der DogFather-Rolle liefern wirklich aus`); } /* ======================================================================= 4. DIE GEGENPROBE: KANN DIESE PRUEFUNG UEBERHAUPT ROT WERDEN? ======================================================================= */ melde(""); melde("=== 4. Gegenprobe -- eine kaputte Tafel MUSS auffallen ==="); /* Dieselbe Vergleichslogik wie in Abschnitt 1, aber mit absichtlich beschaedigten Daten. Steht ganz am Ende, damit ihr Schaden nichts verfaelscht, was davor gemessen wurde -- die Lektion vom 09.09., als eine zerstoerende Probe in der Mitte stand und alles danach verbog. */ { const dateien = readdirSync(SEITEN_ORDNER).filter((f) => f.endsWith(".html")) .map((f) => "/workspace/" + f); const luecke = new Set([...rechte.alleSeiten(), ...rechte.OHNE_ANMELDUNG]); luecke.delete("/workspace/start.html"); ok(dateien.some((p) => !luecke.has(p)), "eine entfernte Seite wird als Luecke erkannt"); const falscheRolle = { "/workspace/x.html": ["gibt-es-nicht"] }; const bekannt = new Set(rechte.ALLE_ROLLEN); const gefunden = Object.values(falscheRolle)[0].some((r) => !bekannt.has(r)); ok(gefunden, "eine unbekannte Rolle in einem Eintrag wird erkannt"); const alsNull = { "/workspace/x.html": null }; ok(!Array.isArray(Object.values(alsNull)[0]), "und ein `null`-Eintrag wird als kein Feld erkannt"); } /* Der Server haelt die Datenbankdatei offen -- unter Windows laesst sie sich dann nicht loeschen. Das ist ein Aufraeumrest im Temp-Ordner, kein Befund, und darf die Pruefung nicht rot machen. */ try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } console.log(""); console.log(`${geprueft} Pruefungen, ${fehler} Fehler`); console.log(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG"); process.exit(fehler === 0 ? 0 : 1);