diff --git a/server/pruef-sackgassen.mjs b/server/pruef-sackgassen.mjs new file mode 100644 index 00000000..cc73c088 --- /dev/null +++ b/server/pruef-sackgassen.mjs @@ -0,0 +1,277 @@ +/* ===================================================================== + pruef-sackgassen.mjs — Kein Weg führt ins Leere. Für JEDE Rolle. + + Filipe, 19.09.2026: „Es darf keine Sackgassen geben. Es darf keine + Funktionen geben, bei denen man sich fragt: Was soll ich hier jetzt + machen?" + + --------------------------------------------------------------------- + DIE LÜCKE, DIE ES ZU SCHLIESSEN GALT + + `pruef-community-sicht` prüft genau das -- aber nur für den **Gast**. + Gemessen am 20.09.2026: Ein Modi hat 25 Kacheln, eine rechte Hand + 30, DogFather 39. Keine dieser drei Rollen war je darauf geprüft + worden, ob ihre Seiten auf etwas verweisen, das sie nicht öffnen + darf. + + Und genau dort ist es zweimal passiert: Am 19.09. warf „Klingelt + nichts?" ALLE DREI Team-Rollen auf die Startseite zurück, und der + Kalender-Verweis führte die Community seit jeher ins Leere. Beides + hat jemand zufällig gefunden, nicht eine Prüfung. + + --------------------------------------------------------------------- + WAS EINE SACKGASSE IST + + Ein sichtbarer Verweis auf eine Seite, die diese Rolle nicht öffnen + darf. Wer darauf tippt, landet auf der Startseite -- ohne ein Wort + dazu, warum. Aus seiner Sicht hat die App ihn abgeworfen. + + Gemessen wird gegen `darfSeite()` aus `rechte.js` -- dieselbe + Tafel, nach der der Server entscheidet. Keine zweite Liste, die + auseinanderlaufen könnte. + + --------------------------------------------------------------------- + WARUM DAS ÜBER 127.0.0.1 GEHT, OBWOHL ES CREW-ROLLEN SIND + + Das Haus einer Person wird NICHT aus der Adresse abgeleitet, + sondern aus ihrer Rolle (`workspace.js`: `TEAM_DOGI_ROLLEN.has(rolle) + ? "crew" : "agentur"`). Ein Modi ist also auch auf 127.0.0.1 im + Crew-Haus. Deshalb braucht diese Prüfung weder eine eigene + HTTPS-Front noch einen umgebogenen Namensdienst -- anders als + `pruef-handy-teamdogi`, die wirklich die Adresse messen muss. + + AUFRUF: node server/pruef-sackgassen.mjs + Dauert rund eine Minute -- vier Rollen, alle ihre Seiten, ein Gerät. + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request as httpAnfrage } from "node:http"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-sackgasse-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +import { notbremse } from "./helfer-notbremse.mjs"; +const { portMussFreiSein } = await import("./helfer-port.mjs"); +const PORT = await portMussFreiSein(4331, "die Sackgassenpruefung"); + +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 }); }; +await import("./index.js"); +notbremse(420_000, "pruef-sackgassen"); +await new Promise((r) => setTimeout(r, 1000)); + +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++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const CREW = "crew.dogfather-universe.com"; +const { darfSeite, SEITEN } = await import("./rechte.js"); +const { bereicheFuer } = await import("./workspace.js"); + +/** Alle Seiten des Workspace -- abgeleitet aus der Rechtetafel, nicht + * aus dem Verzeichnis: Was nicht in der Tafel steht, ist ohnehin + * verboten, und was nur im Verzeichnis liegt, ist keine Seite. */ +const alleSeiten = Object.keys(SEITEN) + .filter((p) => p.startsWith("/workspace/") && p.endsWith(".html")) + .map((p) => p.replace("/workspace/", "")); + +function roh(pfad, kopf = {}, koerper = null) { + return new Promise((f, s) => { + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: pfad, + method: koerper ? "POST" : "GET", + headers: { Host: CREW, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf }, + }, (an) => { + let t = ""; + an.on("data", (c) => { t += c; }); + an.on("end", () => f({ code: an.statusCode, text: t, kopf: an.headers })); + }); + a.on("error", s); + if (koerper) a.write(JSON.stringify(koerper)); + a.end(); + }); +} + +/* ---- Bestand -------------------------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await roh("/workspace/api/ich").catch(() => {}); +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 db = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const h = scryptSync(code, salt, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + db.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,1,?)").run(name, rolle, h, salt, 32768, jetzt); +} + +/** DIE ROLLEN WERDEN ABGELEITET, NICHT AUFGEZÄHLT. + * + * Käme morgen eine fünfte Team-Dogi-Rolle dazu, stünde sie hier + * nicht -- und niemand würde es merken. Genommen wird deshalb, was + * die Anmeldewand selbst anbietet. */ +const { readFileSync } = await import("node:fs"); +const wand = readFileSync(new URL("../workspace/crew-index.html", import.meta.url), "utf8"); +const ROLLEN = [...new Set([...wand.matchAll(/data-rolle="([a-z]+)"/g)].map((m) => m[1]))]; + +const CODES = {}; +for (const r of ROLLEN) { + const code = `CODE-${r.toUpperCase().padEnd(4, "X").slice(0, 4)}-0001`; + CODES[r] = code; + anlegen(r.charAt(0).toUpperCase() + r.slice(1) + "test", r, code); +} +db.close(); + +melde("\n=== Sackgassen: führt irgendein Weg ins Leere? ===\n"); +ok(ROLLEN.length >= 3, `die Anmeldewand bietet ${ROLLEN.length} Rollen an: ${ROLLEN.join(", ")}`); +ok(alleSeiten.length >= 20, `die Rechtetafel kennt ${alleSeiten.length} Seiten`); + +/* ---- Anmelden ------------------------------------------------------- */ +async function anmelden(rolle) { + let a = await roh("/workspace/api/anmelden", {}, { rolle, code: CODES[rolle] }); + /* DIE ALTERSBESTAETIGUNG IST EIN EIGENER SCHRITT -- und zwar nur + fuer den Gast. Ohne sie antwortet der Server mit 400 und + `alter_offen`. Das ist kein Fehler, sondern die Schranke, die + dort hingehoert: Der Treff ist nichts fuer Kinder. + + Erkannt wird sie an der ANTWORT, nicht an der Rolle -- eine + Liste "wer bestaetigen muss" waere morgen falsch, wenn die + Schranke noch woanders gilt. */ + if (a.code === 400 && a.text.includes("alter_offen")) { + a = await roh("/workspace/api/anmelden", {}, { rolle, code: CODES[rolle], alter_ok: true }); + } + const keks = [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); + return { code: a.code, keks }; +} + +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); + +/* Ein Gerät genügt: Eine Sackgasse ist eine Frage der RECHTE, nicht + der Breite. Was am Handy ins Leere führt, tut es am Rechner auch -- + `pruef-handy-teamdogi` misst dafür die Größen. */ +const gesamt = { wege: 0, tot: 0, seiten: 0 }; +const befunde = []; + +try { + for (const rolle of ROLLEN) { + const an = await anmelden(rolle); + if (an.code !== 200 || !an.keks) { ok(false, `${rolle}: Anmeldung (${an.code})`); continue; } + + const erlaubt = alleSeiten.filter((d) => darfSeite({ rolle, haus: "crew" }, `/workspace/${d}`)); + const verboten = alleSeiten.filter((d) => !darfSeite({ rolle, haus: "crew" }, `/workspace/${d}`)); + + const ctx = await browser.newContext({ viewport: { width: 1280, height: 950 } }); + await ctx.addCookies(an.keks.split("; ").filter(Boolean).map((z) => { + const [name, ...rest] = z.split("="); + return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; + })); + const seite = await ctx.newPage(); + const skriptfehler = []; + seite.on("pageerror", (e) => skriptfehler.push(String(e).slice(0, 120))); + + /* Die Bretter hinter bereich.html sehen völlig verschieden aus -- + jedes ist eine eigene Seite. Abgeleitet aus den Kacheln dieser + Rolle, nicht aus einer Liste. */ + const kacheln = bereicheFuer({ rolle, haus: "crew", id: 1, name: "X" }) || []; + const ziele = new Set(erlaubt); + for (const k of kacheln) { + const z = String(k.ziel || ""); + if (z.startsWith("bereich.html?")) ziele.add(z); + } + + let tot = 0, wege = 0; + for (const ziel of ziele) { + await seite.goto(`http://127.0.0.1:${PORT}/workspace/${ziel}`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(700); + gesamt.seiten++; + const gefunden = await seite.evaluate(() => + [...document.querySelectorAll("a[href]")] + .filter((a) => a.offsetParent !== null) + .map((a) => ({ ziel: a.getAttribute("href"), text: a.textContent.trim().slice(0, 34) }))); + for (const w of gefunden) { + const datei = String(w.ziel).split("?")[0].split("#")[0].replace(/^\.?\//, ""); + if (!datei.endsWith(".html")) continue; + wege++; + if (verboten.includes(datei)) { + tot++; + befunde.push(`${rolle} · ${ziel}: „${w.text}" -> ${datei}`); + } + } + } + gesamt.wege += wege; gesamt.tot += tot; + + /* DIE ANZAHL GEHÖRT IN DIE BEDINGUNG: „0 tote Wege" wäre auch + dann wahr, wenn gar keine Seite geladen hätte. */ + ok(wege > 0 && tot === 0, + `${rolle}: ${ziele.size} Seiten, ${wege} sichtbare Wege, ${tot} ins Leere`); + ok(skriptfehler.length === 0, + `${rolle}: keine Skriptfehler${skriptfehler.length ? " — " + skriptfehler[0] : ""}`); + + await ctx.close(); + } +} finally { + await browser.close(); +} + +if (befunde.length) { + melde("\n --- Die Wege ins Leere ---"); + for (const b of befunde.slice(0, 20)) melde(" " + b); + if (befunde.length > 20) melde(` … und ${befunde.length - 20} weitere`); +} + +/* ---- DIE GEGENPROBE ------------------------------------------------- + Eine Prüfung, die immer bestätigt, bestätigt nichts. Hier wird eine + Sackgasse ERFUNDEN und muss gefunden werden. */ +melde("\n --- Gegenprobe (muss anschlagen) ---"); +{ + const rolle = ROLLEN.includes("gast") ? "gast" : ROLLEN[ROLLEN.length - 1]; + const verboten = alleSeiten.filter((d) => !darfSeite({ rolle, haus: "crew" }, `/workspace/${d}`)); + const b2 = await pw.chromium.launch(); + try { + const an = await anmelden(rolle); + const ctx = await b2.newContext({ viewport: { width: 1280, height: 950 } }); + await ctx.addCookies(an.keks.split("; ").filter(Boolean).map((z) => { + const [name, ...rest] = z.split("="); + return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; + })); + const s2 = await ctx.newPage(); + await s2.goto(`http://127.0.0.1:${PORT}/workspace/start.html`, { waitUntil: "networkidle" }); + const erfunden = verboten[0]; + await s2.evaluate((d) => { + const a = document.createElement("a"); + a.href = d; a.textContent = "erfundener Weg"; + document.body.append(a); + }, erfunden); + const jetztTot = await s2.evaluate(() => + [...document.querySelectorAll("a[href]")] + .filter((a) => a.offsetParent !== null) + .map((a) => a.getAttribute("href"))); + ok(jetztTot.includes(erfunden) && verboten.includes(erfunden), + `ein erfundener Weg nach ${erfunden} wird gefunden`); + await ctx.close(); + } finally { await b2.close(); } +} + +melde(`\n${fehler ? fehler + " FEHLER" : "Alles in Ordnung"} — ${geprueft} Prüfungen`); +melde(` (${ROLLEN.length} Rollen, ${gesamt.seiten} Seitenansichten, ` + + `${gesamt.wege} sichtbare Wege, ${gesamt.tot} ins Leere)\n`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler ? 1 : 0);