From 9e9ed67c4ab29283775c6aa86e62cdceb0478e23 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Sun, 20 Sep 2026 12:52:51 +0200 Subject: [PATCH] Keine Sackgassen -- fuer JEDE Rolle, nicht nur fuer den Gast Filipe: "Es darf keine Sackgassen geben." pruef-community-sicht prueft genau das -- aber nur fuer den GAST. Ein Modi hat 25 Kacheln, eine rechte Hand 30, DogFather 39. Keine dieser drei Rollen war je darauf geprueft worden, ob ihre Seiten auf etwas verweisen, das sie nicht oeffnen darf. Und genau dort ist es zweimal passiert: Am 19.09. warf "Klingelt nichts?" ALLE DREI Team-Rollen auf die Startseite zurueck, und der Kalender-Verweis fuehrte die Community seit jeher ins Leere. Beides hat jemand zufaellig gefunden, nicht eine Pruefung. Neu: server/pruef-sackgassen.mjs -- 11/0. 4 Rollen, 126 Seitenansichten, 582 sichtbare Wege, 0 ins Leere. DREIMAL ABGELEITET STATT AUFGEZAEHLT: Die ROLLEN kommen aus der Anmeldewand (data-rolle), nicht aus einer Liste. Kaeme eine fuenfte dazu, stuende sie sonst nicht drin und niemand wuerde es merken. Die SEITEN kommen aus der Rechtetafel -- derselben, nach der der Server entscheidet. Keine zweite Liste, die auseinanderlaufen kann. Die BRETTER hinter bereich.html kommen aus den Kacheln dieser Rolle. Die Altersbestaetigung des Gastes wird an der ANTWORT erkannt (400 + alter_offen), nicht an der Rolle: Eine Liste "wer bestaetigen muss" waere morgen falsch, wenn die Schranke noch woanders gilt. Und sie braucht weder HTTPS-Front noch umgebogenen Namensdienst: Das Haus einer Person kommt aus ihrer ROLLE, nicht aus der Adresse. Ein Modi ist auch auf 127.0.0.1 im Crew-Haus. GEGENPROBE: Ein erfundener Weg auf eine gesperrte Seite wird gefunden. Ohne sie waere "0 ins Leere" auch dann wahr, wenn nichts geladen haette -- deshalb steht die Zahl der Wege zusaetzlich in der Bedingung. --- server/pruef-sackgassen.mjs | 277 ++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 server/pruef-sackgassen.mjs 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);