/* ===================================================================== DIE ANLEITUNG — stimmt sie mit den Kacheln ueberein? (Etappe 1) ===================================================================== DIE EIGENTLICHE FRAGE DIESER DATEI steht im Bauplan gar nicht; sie kam beim Nachmessen dazu, und sie ist der Grund, warum es diese Pruefung ueberhaupt gibt: Erklaert die Anleitung genau die Kacheln, die es gibt -- und zwar JEDE und KEINE DARUEBER HINAUS? Beides kann lautlos kaputtgehen, und beides auf dieselbe Weise: Wer eine Kachel hinzufuegt, vergisst die Karte -- dann steht auf der Startseite etwas, das die Einweisung nicht kennt. Wer eine Kachel entfernt, laesst die Karte stehen -- dann erklaert die Einweisung etwas, das es nicht mehr gibt, mit einem Knopf, der ins Leere fuehrt. Der Bauplan sieht dafuer nur einen Hinweis in der Pflege-Ansicht vor ("Fuer diese Kachel fehlt ein Eintrag"). Der hilft nur, wenn jemand hinsieht. Diese Pruefung sieht immer hin. --------------------------------------------------------------------- WOHER DIE KACHELLISTE KOMMT Aus `workspace/assets/js/bereiche.js`, und zwar indem die Datei AUSGEFUEHRT wird -- nicht, indem jemand sie mit einem Muster durchsucht. Ein Muster waere eine zweite, ungenaue Nachbildung der Datei; sie wuerde bei der ersten ungewohnten Schreibweise still danebenliegen und dann "alles in Ordnung" melden. Der Server kennt diese Liste nicht (bereicheFuer gibt fuers Agenturhaus `null` zurueck). Deshalb muss die Pruefung sie sich selbst holen -- genau wie der Browser es tut. ===================================================================== */ import { mkdtempSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import vm from "node:vm"; import { eigenerPort } from "./helfer-port.mjs"; const PORT = await eigenerPort(import.meta, "pruef-anleitung"); const ordner = mkdtempSync(join(tmpdir(), "ws-anl-")); 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-anleitung"); await new Promise((r) => setTimeout(r, 800)); const BASIS = `http://127.0.0.1:${PORT}`; 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++; }; function braucht(bedingung, was) { if (bedingung) return; console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}`); console.log("Das ist KEIN Befund am Code. Die Pruefung konnte ihre"); console.log("Voraussetzung nicht herstellen und sagt das, statt zu schweigen.\n"); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } process.exit(2); } /* ===================================================================== DIE KACHELLISTE — bereiche.js wirklich ausfuehren ===================================================================== */ function bereicheLaden() { const quelle = readFileSync( new URL("../workspace/assets/js/bereiche.js", import.meta.url), "utf8"); const fenster = {}; const tot = () => ({ setAttribute() {}, append() {}, style: { setProperty() {} } }); const umgebung = { window: fenster, URLSearchParams, document: { createElement: tot, createElementNS: tot } }; umgebung.globalThis = umgebung; vm.createContext(umgebung); vm.runInContext(quelle, umgebung, { filename: "bereiche.js" }); return fenster.Bereiche; } const B = bereicheLaden(); braucht(B && Array.isArray(B.GRUPPEN) && B.GRUPPEN.length > 0, "bereiche.js liefert keine Kachelliste — ohne sie ist nichts zu vergleichen"); braucht(typeof B.schluesselVonZiel === "function", "bereiche.js kennt schluesselVonZiel nicht"); melde("\n=== Der Kachel-Schluessel rechnet richtig ==="); const SCHLUESSEL_PROBEN = [ ["uebersicht.html", "uebersicht"], ["bereich.html?b=live", "bereich:live"], ["bereich.html?b=agentur", "bereich:agentur"], ["profil.html", "profil"], ["chat.html?raum=treff", "chat"], ["", ""], ]; for (const [ziel, erwartet] of SCHLUESSEL_PROBEN) { const ist = B.schluesselVonZiel(ziel); ok(ist === erwartet, `"${ziel || "(leer)"}" -> "${ist}" (erwartet "${erwartet}")`); } /* GEGENPROBE: Die Rechnung muss auch TRENNEN koennen. Lieferte sie fuer alles dasselbe, waeren die sechs Zeilen oben trotzdem teils gruen. */ ok(B.schluesselVonZiel("bereich.html?b=live") !== B.schluesselVonZiel("bereich.html?b=technik"), "Gegenprobe: zwei Bereiche derselben Seite bekommen verschiedene Schluessel"); function kachelnFuer(rolle) { const nach = new Map(); for (const g of B.GRUPPEN) { for (const k of (g.bereiche || [])) { if (Array.isArray(k.rollen) && !k.rollen.includes(rolle)) continue; const s = B.schluesselVonZiel(k.ziel); if (s) nach.set(s, k); } } return nach; } /* ===================================================================== LEUTE UND ANMELDUNG ===================================================================== */ const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetztIso = new Date().toISOString(); const kennSchluessel = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", kennSchluessel, jetztIso); 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", kennSchluessel).update(code).digest("hex"), jetztIso); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } const ROLLEN = [["admin", "Filipe"], ["spicy", "Spicy Media"], ["manager", "Patrick"], ["scout", "Kevin"], ["creator", "Luna"], ["modi", "Miss"], ["hand", "VanVan"]]; const CODES = {}; for (const [i, [rolle, name]] of ROLLEN.entries()) { CODES[rolle] = `CODE-A${String(i).padStart(2, "0")}-000001`; anlegen(name, rolle, CODES[rolle]); } /* Wie viele Zeilen hat die Erstbefuellung angelegt? Direkt in der Datenbank nachgezaehlt und nicht ueber die Schnittstelle: Sonst pruefte man die Schnittstelle mit sich selbst. */ const inDb = d.prepare( "SELECT COUNT(*) n FROM anl_karte WHERE haus = 'agentur' AND rolle = 'creator'").get().n; d.close(); const { request } = await import("node:http"); const CREW_HOST = "crew.dogfather-universe.com"; function roh(pfad, host, koerper, keks) { return new Promise((fertig, schief) => { const daten = koerper ? JSON.stringify(koerper) : null; const a = request({ host: "127.0.0.1", port: PORT, path: pfad, method: daten ? "POST" : "GET", headers: { ...(host ? { Host: host } : {}), Origin: host ? `https://${host}` : BASIS, ...(keks ? { Cookie: keks } : {}), ...(daten ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(daten) } : {}), }, }, (antwort) => { let text = ""; antwort.on("data", (t) => { text += t; }); antwort.on("end", () => fertig({ code: antwort.statusCode, kopf: antwort.headers, text })); }); a.on("error", schief); if (daten) a.write(daten); a.end(); }); } async function anmelden(rolle, host = null) { const r = await roh("/workspace/api/anmelden", host, { rolle, code: CODES[rolle] }); if (r.code !== 200) return null; return [].concat(r.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ") || null; } melde("\n=== Alle Rollen kommen herein ==="); const kekse = {}; for (const [rolle] of ROLLEN) { const haus = (rolle === "modi" || rolle === "hand") ? CREW_HOST : null; kekse[rolle] = await anmelden(rolle, haus); ok(!!kekse[rolle], `${rolle}: angemeldet`); } braucht(Object.values(kekse).every(Boolean), "nicht alle Rollen konnten sich anmelden"); const holen = async (rolle, host = null) => { const r = await roh("/workspace/api/anleitung", host, null, kekse[rolle]); let j = null; try { j = JSON.parse(r.text); } catch { /* bleibt null */ } return { code: r.code, j }; }; /* ===================================================================== DIE ERSTBEFUELLUNG ===================================================================== */ melde("\n=== Die Inhalte sind eingespielt ==="); ok(inDb === 18, `Creator hat 18 Karten in der Datenbank (${inDb})`); const creator = await holen("creator"); ok(creator.code === 200, `Creator bekommt die Anleitung (HTTP ${creator.code})`); braucht(creator.code === 200 && creator.j, "die Schnittstelle antwortet dem Creator nicht"); ok(creator.j.hat_fassung === true, "Creator hat eine eigene Fassung"); ok(creator.j.anzahl === 18, `18 Karten ueber die Schnittstelle (${creator.j.anzahl})`); ok((creator.j.saetze || []).length === 3, `drei Saetze „In 60 Sekunden" (${(creator.j.saetze || []).length})`); ok((creator.j.schritte || []).length === 5, `fuenf Erste Schritte (${(creator.j.schritte || []).length})`); ok((creator.j.nicht || []).length === 5, `fuenf Zeilen „Nicht deins" (${(creator.j.nicht || []).length})`); ok((creator.j.takt || []).length === 4, `vier Rhythmus-Zeilen (${(creator.j.takt || []).length})`); ok((creator.j.fragen || []).length === 4, `vier haeufige Fragen (${(creator.j.fragen || []).length})`); ok(!!creator.j.leitgedanke && creator.j.leitgedanke.length > 40, "der Leitgedanke steht da"); /* DIE ZAHLEN DER ABNAHMELISTE — GERECHNET, NICHT ABGESCHRIEBEN. Der Bauplan nennt 5/6/7. Diese Zeile prueft nicht gegen die Zahl, sondern gegen die Summe: Zusammen muessen es so viele sein, wie es Karten gibt, und keine Karte darf eine unbekannte Stufe tragen. */ melde("\n=== Die Stufen ==="); const jeStufe = {}; for (const k of creator.j.karten) jeStufe[k.stufe] = (jeStufe[k.stufe] || 0) + 1; const bekannt = new Set((creator.j.stufen || []).map((s) => s.schluessel)); ok([...Object.keys(jeStufe)].every((s) => bekannt.has(s)), `jede Karte traegt eine bekannte Stufe (${Object.keys(jeStufe).join(", ")})`); ok(Object.values(jeStufe).reduce((a, b) => a + b, 0) === creator.j.karten.length, "die Stufen zusammen ergeben alle Karten"); ok(jeStufe.muss === 5 && jeStufe.regelmaessig === 6 && jeStufe.bedarf === 7, `Verteilung 5/6/7 wie im Bauplan (${jeStufe.muss}/${jeStufe.regelmaessig}/${jeStufe.bedarf})`); /* ===================================================================== DIE ZUORDNUNG — IN BEIDE RICHTUNGEN ===================================================================== */ melde("\n=== Jede Kachel hat eine Karte, jede Karte eine Kachel ==="); const kacheln = kachelnFuer("creator"); /* Die Anleitung selbst bekommt keine Karte. Eine Karte, die die Seite erklaert, auf der man gerade steht, ist Fuellmaterial. */ kacheln.delete("anleitung"); braucht(kacheln.size > 0, "fuer den Creator findet sich keine einzige Kachel"); const habenKarte = new Set(creator.j.karten.map((k) => k.kachel)); const ohneKarte = [...kacheln.keys()].filter((s) => !habenKarte.has(s)); const ohneKachel = [...habenKarte].filter((s) => !kacheln.has(s)); ok(ohneKarte.length === 0 && kacheln.size > 0, ohneKarte.length ? `${ohneKarte.length} Kacheln OHNE Karte: ${ohneKarte.join(", ")}` : `alle ${kacheln.size} Kacheln des Creators haben eine Karte`); ok(ohneKachel.length === 0 && habenKarte.size > 0, ohneKachel.length ? `${ohneKachel.length} Karten OHNE Kachel: ${ohneKachel.join(", ")}` : `alle ${habenKarte.size} Karten zeigen auf eine Kachel, die es gibt`); /* GEGENPROBE. Beide Zeilen oben koennen "in Ordnung" sagen. Koennen sie auch "nicht in Ordnung"? Ohne diesen Beweis waere eine leere Menge auf beiden Seiten gruen -- und genau so sieht ein kaputter Abgleich aus. */ const erfundeneKachel = new Set([...kacheln.keys(), "gibtesnicht"]); ok([...erfundeneKachel].filter((s) => !habenKarte.has(s)).length === 1, "Gegenprobe: eine Kachel ohne Karte wird gefunden"); const erfundeneKarte = new Set([...habenKarte, "auchnicht"]); ok([...erfundeneKarte].filter((s) => !kacheln.has(s)).length === 1, "Gegenprobe: eine Karte ohne Kachel wird gefunden"); /* Jede Karte muss alle drei Saetze haben. Eine halb gefuellte Karte sieht aus wie ein Ladefehler. */ const luecken = creator.j.karten.filter((k) => !k.wozu || !k.tust || !k.merke); ok(luecken.length === 0, luecken.length ? `${luecken.length} Karten ohne vollstaendige drei Zeilen: ` + luecken.map((k) => k.kachel).join(", ") : "jede Karte hat Wozu, Was du hier tust und Merke"); /* Die Erste-Schritte-Ziele muessen Kacheln dieser Rolle sein -- oder gar kein Ziel haben ("Installieren" ist ein Knopf, keine Seite). Ein Sprung auf eine Seite, die ein Creator nicht oeffnen darf, waere ein Knopf, der auf die Startseite zurueckwirft. */ const schrittFehler = (creator.j.schritte || []) .filter((s) => s.ziel) .filter((s) => !kachelnFuer("creator").has(B.schluesselVonZiel(s.ziel))); ok(schrittFehler.length === 0, schrittFehler.length ? `Erste Schritte zeigen ins Leere: ${schrittFehler.map((s) => s.ziel).join(", ")}` : "jeder Erste Schritt mit Ziel fuehrt auf eine Kachel dieser Rolle"); /* ===================================================================== WER DARF, WER NICHT ===================================================================== */ melde("\n=== Die drei Schranken ==="); const admin = await holen("admin"); ok(admin.code === 200 && admin.j?.hat_fassung === false, `DogFather bekommt keine Fassung, sondern eine Auskunft (HTTP ${admin.code}, ` + `hat_fassung ${admin.j?.hat_fassung})`); ok(!!admin.j?.satz && admin.j.satz.length > 30, "…und die Auskunft sagt ihm, wie er trotzdem hineinsieht"); ok(admin.j?.darf_pflegen === true, "DogFather darf pflegen"); const spicy = await holen("spicy"); ok(spicy.code === 200, `Spicy Media bekommt eine Antwort (HTTP ${spicy.code})`); ok(spicy.j?.darf_pflegen === true, "Spicy Media darf pflegen (Entscheidung 06.10.2026, wie bei den Manager-Zielen)"); /* Etappe 1 bringt nur den Creator. Scout, Manager und Spicy haben noch keine Inhalte -- das ist der Stand der Arbeit und kein Fehler. Es wird aber AUSGESPROCHEN, nicht uebersprungen. */ melde(` -- Scout, Manager und Spicy Media haben noch keine Inhalte ` + `(Etappe 3) — Spicy meldet ${spicy.j?.anzahl ?? 0} Karten`); const creatorPflege = creator.j.darf_pflegen; ok(creatorPflege === false, "ein Creator darf NICHT pflegen"); const modi = await holen("modi", CREW_HOST); ok(modi.code === 404, `ein Modi bekommt die Anleitung nicht (HTTP ${modi.code}, erwartet 404)`); const hand = await holen("hand", CREW_HOST); ok(hand.code === 404, `die rechte Hand bekommt sie auf der Crew-Adresse nicht (HTTP ${hand.code}, erwartet 404)`); /* Die Seite selbst, nicht nur die Schnittstelle. ===================================================================== GEPRUEFT WIRD DAS ZIEL, NICHT NUR DIE 302. Das Haus beantwortet "darfst du nicht" mit einer Umleitung und nicht mit 403 (workspace.js, bei `darfSeite`) -- ausdruecklich, damit es auf "nein" nur EINE Antwort gibt und niemand aus dem Unterschied etwas ablesen kann. Genau deshalb reicht hier "Status ist 302" nicht: NICHT ANGEMELDET gibt ebenfalls 302. Eine Pruefung auf die blosse Zahl waere auch dann gruen, wenn die Sitzung des Modi gar nicht erkannt wuerde -- sie haette dann die Anmeldung gemessen und nicht die Schranke. Unterscheidbar sind die beiden nur am Ziel: nicht angemeldet -> /workspace/ angemeldet, aber gesperrt -> /workspace/start.html ===================================================================== */ const seiteCreator = await roh("/workspace/anleitung.html", null, null, kekse.creator); ok(seiteCreator.code === 200, `anleitung.html oeffnet fuer den Creator (${seiteCreator.code})`); const seiteModi = await roh("/workspace/anleitung.html", CREW_HOST, null, kekse.modi); ok(seiteModi.code === 302 && seiteModi.kopf.location === "/workspace/start.html", `ein angemeldeter Modi wird zur Startseite umgeleitet ` + `(${seiteModi.code} -> ${seiteModi.kopf.location})`); const seiteOhne = await roh("/workspace/anleitung.html", null, null, null); ok(seiteOhne.code === 302 && seiteOhne.kopf.location === "/workspace/", `ohne Anmeldung zur Anmeldung, nicht zur Startseite ` + `(${seiteOhne.code} -> ${seiteOhne.kopf.location})`); /* GEGENPROBE ZU DEN BEIDEN: Sie unterscheiden wirklich. Zeigten beide Umleitungen auf dasselbe, waeren die Zeilen oben zusammen trotzdem gruen zu bekommen -- und die Schranke waere ungeprueft. */ ok(seiteModi.kopf.location !== seiteOhne.kopf.location, "Gegenprobe: gesperrt und nicht angemeldet fuehren an verschiedene Orte"); /* ===================================================================== DIE ERSTBEFUELLUNG DARF NICHTS UEBERSCHREIBEN ===================================================================== Die wichtigste Zeile in anleitung-tabellen.js: Eingespielt wird nur, wenn fuer eine Rolle noch gar nichts da ist. Liefe sie bei jedem Start, waere jede Aenderung aus der Pflege-Ansicht beim naechsten Neustart weg -- lautlos, und man merkte es Wochen spaeter. */ melde("\n=== Ein zweiter Lauf laesst Aenderungen in Ruhe ==="); const d2 = new DatabaseSync(process.env.WORKSPACE_DB); d2.prepare("UPDATE anl_karte SET merke = ? WHERE haus='agentur' AND rolle='creator' AND kachel='chat'") .run("VON HAND GEAENDERT"); const { anleitungTabellen } = await import("./anleitung-tabellen.js"); anleitungTabellen(d2); const nachher = d2.prepare( "SELECT merke FROM anl_karte WHERE haus='agentur' AND rolle='creator' AND kachel='chat'").get(); const anzahlNachher = d2.prepare( "SELECT COUNT(*) n FROM anl_karte WHERE haus='agentur' AND rolle='creator'").get().n; d2.close(); ok(nachher?.merke === "VON HAND GEAENDERT", `der geaenderte Text steht noch da ("${(nachher?.merke || "").slice(0, 24)}")`); ok(anzahlNachher === 18, `und es sind keine Karten dazugekommen (${anzahlNachher})`); /* ---------- Abschluss ------------------------------------------------ */ try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } console.log(`\n${fehler ? "FEHLER: " + fehler : "Alles in Ordnung"} — ${geprueft} Prüfungen`); process.exit(fehler ? 1 : 0);