/* ===================================================================== Prüfung: das Formular "Neue Person" und die Rechte auf den beiden Verwaltungsbereichen (02.09.2026). Zwei Meldungen: "das sieht ja mal richtig scheisse aus. mach das besser bitte." -- Die Rollenauswahl lief unten aus dem Formular heraus und legte sich über die Personenliste. Ursache war keine schlampige Gestaltung, sondern eine Raster-Regel für EINZEILIGE Felder (feste Zeilenhöhe 44 px), die auch auf den Block aus vier hohen Karten angewandt wurde. "die manager sollen diese kategorien garnicht sehen" -- "Personen & Zugänge" und "Automationen" gehören allein DogFather. Der zweite Punkt wird auf DREI Ebenen geprüft, nicht nur an der Kachel: Wegnehmen, was man sieht, ist kein Rechteentzug. Es zählt, was der Server antwortet. ===================================================================== */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const ordner = mkdtempSync(join(tmpdir(), "ws-pform-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = "4198"; process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-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"; 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-personen-formular"); await new Promise((r) => setTimeout(r, 700)); const BASIS = "http://127.0.0.1:4198"; setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 300_000).unref?.(); let fehler = 0; let geprueft = 0; const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes } = await import("node:crypto"); 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; } anlegen("Dogfather", "admin", "CODE-DOGI-0001"); anlegen("Cigdem", "manager", "CODE-CIGDEM-01"); anlegen("Tili", "creator", "CODE-TILI-0001"); d.close(); const { chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await chromium.launch(); async function anmelden(rolle, code, breite = 1440) { const kontext = await browser.newContext({ viewport: { width: breite, height: 1000 } }); const seite = await kontext.newPage(); await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); await seite.click(`.rolle[data-rolle="${rolle}"]`); await seite.fill("#code", code); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 15000 }); return seite; } /* =================================================================== TEIL 1 — Das Formular läuft nicht mehr über =================================================================== */ console.log("\n— Das Formular „Neue Person“ —"); const dogi = await anmelden("admin", "CODE-DOGI-0001"); await dogi.goto(BASIS + "/workspace/personen.html", { waitUntil: "networkidle" }); await dogi.waitForTimeout(700); await dogi.click("#neu-oeffnen").catch(() => {}); await dogi.waitForTimeout(500); const mass = await dogi.evaluate(() => { const form = document.getElementById("neu"); const wahl = document.getElementById("f-rolle"); const karten = [...document.querySelectorAll(".rollenwahl__knopf")]; const f = form.getBoundingClientRect(); const w = wahl.getBoundingClientRect(); /* Das erste Element NACH dem Formular -- über das die Karten vorher hinweggelaufen sind. */ const danach = document.querySelector("#liste"); const dn = danach ? danach.getBoundingClientRect() : null; return { formUnten: Math.round(f.bottom), wahlUnten: Math.round(w.bottom), wahlOben: Math.round(w.top), listeOben: dn ? Math.round(dn.top) : null, karten: karten.length, kartenRollen: karten.map((k) => k.dataset.rolle), /* Welches Zeichen an welcher Karte haengt -- fuer die Frage, ob zwei Rollen dasselbe tragen. */ kartenZeichen: Object.fromEntries(karten.map((k) => [k.dataset.rolle, (k.querySelector("use") || {}).getAttribute?.("href") || ""])), kartenTexte: Object.fromEntries(karten.map((k) => [k.dataset.rolle, (k.querySelector(".rollenwahl__erklaerung") || {}).textContent || ""])), kartenBreiten: karten.map((k) => Math.round(k.getBoundingClientRect().width)), /* Liegt eine Karte weiter rechts als das Formular breit ist? */ ueberRand: karten.some((k) => k.getBoundingClientRect().right > f.right + 1), }; }); /* SECHS, seit es die verborgene Modi-Rolle gibt (09.09.2026) -- vorher fünf, seit es Spicy Media gibt. Die Erwartung steht hier absichtlich fest und wird nicht aus der Oberfläche abgeleitet: Fiele eine Rolle aus der Wahl, müsste das auffallen -- eine Zahl, die sich selbst nachzählt, kann das nicht. SIEBEN SEIT DEM 10.09.2026. Filipe: "3 rollen. dogfather. rechte hand und modis." Die Stellvertretung ist damit die siebte Karte -- und sie kommt auf demselben Weg wie die sechste. DIE SECHSTE UND SIEBTE SIND NICHT WIE DIE ANDEREN. Die fünf stehen in assets/js/personen.js, also in einer Datei, die jeder bekommt, der die Seite öffnet. "Modi" darf dort nicht stehen -- ein Blick in den Quelltext würde sonst genügen, um den verborgenen Zugang zu entdecken. Sie kommt deshalb aus der Antwort des Servers und nur an die DogFather-Rolle. Dass sie hier auftaucht, ist gleichzeitig der Nachweis, dass dieser Weg funktioniert. Geprüft wird ab jetzt die LISTE und nicht nur die Anzahl: Sechs Karten könnten auch fünf richtige und eine doppelte sein. */ ok(mass.karten === 7, `alle sieben Rollen stehen zur Wahl (${mass.karten})`); ok(JSON.stringify([...mass.kartenRollen].sort()) === JSON.stringify(["admin", "creator", "hand", "manager", "modi", "scout", "spicy"]), `und es sind die richtigen (${mass.kartenRollen.join(", ")})`); /* DIE STELLVERTRETUNG TRAEGT EIN EIGENES ZEICHEN. Zweimal derselbe Husky waere auf zwei getrennten Kacheln in Ordnung (dort unterscheiden Name und Farbe), in einer AUSWAHL aber nicht: Dort liegen sie untereinander, und zwei gleiche Zeichen laden zum Vertippen ein. Gemessen wird deshalb, dass es sich vom Husky UNTERSCHEIDET -- nicht, wie es aussieht. */ /* Hier stand "die Stellvertretung hat ein eigenes Zeichen" -- das Gegenteil dessen, was seit dem 10.09.2026 gilt. Umgedreht waere es dieselbe Pruefung wie oben, also ist die Zeile entfallen statt verdoppelt. */ /* WER DASSELBE ZEICHEN TRAEGT WIE DOGFATHER -- UND WER NICHT. Filipe, 10.09.2026 mit Bildschirmfoto: "bei rechte hand soll der husky sein und bei modis soll die pfote sein." Vorher trug der Modi den Husky ("das gleiche wie bei dogfather"); mit der dritten Rolle ist daraus eine Gruppe geworden -- Husky bei den beiden mit dem Ueberblick, Pfote bei der taeglichen Arbeit. Verglichen wird gegen die Admin-Karte und nicht gegen "#r-husky": Waere dort morgen ein anderes Zeichen, muesste die rechte Hand mitwandern -- gewollt ist "dasselbe wie er", nicht "der Husky". */ ok(mass.kartenZeichen.hand && mass.kartenZeichen.hand === mass.kartenZeichen.admin, `die Rechte Hand trägt dasselbe Zeichen wie DogFather (${mass.kartenZeichen.hand} / ` + `${mass.kartenZeichen.admin})`); /* DIE GEGENPROBE GLEICH DANEBEN: Der Modi muss sich davon unterscheiden. Ohne sie waere die Zeile darueber auch dann gruen, wenn alle drei Karten dasselbe Zeichen truegen -- und genau so sah es bis heute Nachmittag aus. */ ok(mass.kartenZeichen.modi && mass.kartenZeichen.modi !== mass.kartenZeichen.admin, `und der Modi ein eigenes (${mass.kartenZeichen.modi})`); /* Und die Beschreibung ist auf Deutsch geschrieben, nicht auf ASCII. Auf dem Bildschirmfoto stand "Sichtbar nur fuer die DogFather-Rolle" -- in einem Text, den jemand liest, sieht das aus wie ein Fehler, weil es einer ist. */ ok(mass.kartenTexte.modi && !/(fuer|ueber|waehrend|koennen|moeglich)/ .test(mass.kartenTexte.modi), `und ihre Beschreibung hat echte Umlaute ("${mass.kartenTexte.modi.slice(0, 60)}…")`); ok(mass.wahlUnten <= mass.formUnten, `die Rollenauswahl endet INNERHALB des Formulars (${mass.wahlUnten} ≤ ${mass.formUnten})`); ok(!mass.ueberRand, "keine Karte ragt seitlich aus dem Formular"); ok(mass.listeOben === null || mass.wahlUnten <= mass.listeOben, `die Karten liegen nicht über der Personenliste (${mass.wahlUnten} ≤ ${mass.listeOben})`); /* Alle Karten gleich breit -- ungleiche Kacheln sind das, was "sieht scheisse aus" in Zahlen bedeutet. */ const breiteste = Math.max(...mass.kartenBreiten); const schmalste = Math.min(...mass.kartenBreiten); ok(breiteste - schmalste <= 2, `alle Karten sind gleich breit (${schmalste}–${breiteste} px)`); /* Die gewählte Rolle muss man SEHEN, nicht erraten. */ const vorher = await dogi.evaluate(() => getComputedStyle(document.querySelector('.rollenwahl__knopf[data-an="ja"]'), "::after").content); ok(vorher.includes("✓"), `die gewählte Rolle trägt ein Häkchen (${vorher})`); await dogi.screenshot({ path: "pruef-personen-formular.png", fullPage: false }); /* Auf dem Handy darf nichts herausragen. */ const handy = await anmelden("admin", "CODE-DOGI-0001", 390); await handy.goto(BASIS + "/workspace/personen.html", { waitUntil: "networkidle" }); await handy.waitForTimeout(700); await handy.click("#neu-oeffnen").catch(() => {}); await handy.waitForTimeout(500); const engUeber = await handy.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); ok(engUeber <= 0, `auf dem Handy ragt nichts heraus (${engUeber}px)`); await handy.screenshot({ path: "pruef-personen-formular-handy.png", fullPage: false }); /* =================================================================== TEIL 2 — Die beiden Bereiche gehören DogFather allein =================================================================== */ console.log("\n— Automationen: nur DogFather. Personen: auch die Leitung —"); /* AM 07.09.2026 GETEILT. Hier standen beide Bereiche unter derselben Regel: „gehören DogFather allein". Für die Automationen gilt das weiter. Für die Personenseite hat Filipe es umgedreht -- Manager sollen „creator und auch wirklich nur creator hinzufügen können", und dafür müssen sie hinein. Aus einer Regel sind damit ZWEI geworden, und beide werden gemessen: Die Seite geht auf, die Verwaltungsdaten dahinter bleiben zu. Genau diese Trennung IST die Sicherung. Hätte ich hier nur die Zeilen gelöscht, wäre der neue Weg ab sofort ungeprüft -- und die Zahl der Prüfungen wäre gesunken, ohne dass jemand hinsieht. */ const cigdem = await anmelden("manager", "CODE-CIGDEM-01"); await cigdem.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" }); await cigdem.waitForTimeout(800); const kacheln = await cigdem.evaluate(() => [...document.querySelectorAll(".kachel")].map((k) => k.innerText.split("\n")[0].trim())); ok(kacheln.some((t) => /Personen/.test(t)), "1. die Kachel „Personen & Zugänge“ steht jetzt bei der Managerin"); ok(!kacheln.some((t) => /Automationen/.test(t)), "1. die Kachel „Automationen“ fehlt weiterhin"); ok(kacheln.length > 8, ` – und der Rest steht auch da (${kacheln.length} Kacheln)`); /* 2. Die SEITEN, falls jemand die Adresse kennt. */ { await cigdem.goto(BASIS + "/workspace/automation.html", { waitUntil: "networkidle" }); ok(cigdem.url().endsWith("/start.html"), `2. automation.html führt zurück zur Startseite (${cigdem.url().split("/").pop()})`); await cigdem.goto(BASIS + "/workspace/personen.html", { waitUntil: "networkidle" }); await cigdem.waitForTimeout(900); ok(cigdem.url().endsWith("/personen.html"), `2. personen.html geht bei ihr auf (${cigdem.url().split("/").pop()})`); /* Und sie sieht dort NUR das Anlegen -- keine Liste, kein Protokoll, und keinen Ladehinweis ohne Ende. Der stand dort wirklich: `laden()` wird für sie mit Absicht nicht aufgerufen, und niemand hat aria-busy zurückgesetzt. Für einen Screenreader lud die Seite dann für immer. */ const seitenbild = await cigdem.evaluate(() => ({ nurAnlegen: document.body.dataset.nurAnlegen || null, laedtNoch: document.querySelectorAll('[aria-busy="true"]').length, erklaerung: document.getElementById("unterzeile")?.textContent.trim() || "", formularDa: !!document.getElementById("neu") && !document.getElementById("neu").hidden, })); ok(seitenbild.nurAnlegen === "ja" && seitenbild.formularDa === true, "2. sie sieht dort das Anlegen-Formular"); ok(seitenbild.laedtNoch === 0, `2. und nichts hängt auf „wird geladen“ (${seitenbild.laedtNoch})`); ok(seitenbild.erklaerung.includes("DogFather"), `2. ein Satz erklärt, warum hier so wenig steht ("${seitenbild.erklaerung.slice(0, 40)}…")`); } /* 3. Die SCHNITTSTELLE, falls jemand ganz an der Seite vorbeigeht. Das ist die einzige Ebene, die wirklich zählt -- die beiden darüber sind Bequemlichkeit. */ const api = (seite, pfad, optionen) => seite.evaluate(async ([p, o]) => { const a = await fetch(p, { credentials: "same-origin", ...(o || {}) }); return a.status; }, [pfad, optionen]); await cigdem.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" }); ok(await api(cigdem, "/workspace/api/verwaltung/personen") === 404, "3. die Personenverwaltung antwortet ihr mit 404"); ok(await api(cigdem, "/workspace/api/zustand") === 404, "3. der Systemzustand ebenfalls"); ok(await api(cigdem, "/workspace/api/ki/schalter", { method: "PUT" }) !== 200, "3. und den KI-Schalter darf sie nicht umlegen"); /* GEGENPROBE: Bei DogFather geht das alles weiterhin. Ohne sie wäre jede Zeile oben auch dann grün, wenn die Bereiche für ALLE kaputt wären -- und das würde erst auffallen, wenn er sie braucht. */ ok(await api(dogi, "/workspace/api/verwaltung/personen") === 200, "GEGENPROBE: DogFather kommt weiterhin an die Personenverwaltung"); ok(await api(dogi, "/workspace/api/zustand") === 200, "GEGENPROBE: und an den Systemzustand"); /* Und die Managerin behält, was sie behalten soll. */ ok(await api(cigdem, "/workspace/api/aufgaben") === 200, "die Managerin arbeitet ansonsten unverändert weiter (Aufgaben)"); ok(await api(cigdem, "/workspace/api/calls") === 200, " und die Gespräche auch"); const ERWARTET = 17; console.log(""); ok(geprueft >= ERWARTET, `es sind ${geprueft} Prüfungen gelaufen (erwartet mindestens ${ERWARTET})`); await browser.close(); 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);