/* ===================================================================== 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, 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), }; }); /* FÜNF, seit es Spicy Media gibt. Die Zahl steht hier absichtlich fest und wird nicht aus der Oberfläche abgeleitet: Sie ist die Erwartung, gegen die gemessen wird. Fiele eine Rolle aus der Wahl, müsste das auffallen -- eine Zahl, die sich selbst nachzählt, kann das nicht. */ ok(mass.karten === 5, `alle fünf Rollen stehen zur Wahl (${mass.karten})`); 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);