Aus dem Anforderungsdokument (Master-Blueprint V3.0) fehlte die Rolle
"Modi" ganz -- es gab nur spicy, admin, manager, scout, creator. Filipes
Bedingung dazu: "dass die keine neue eingangs kachel bekommen wie spicy
dogfather und so sondern einfach einen code. damit die von der workspace
auch nicht mal sehen dass die modis von mir einen eigenen zugang haben."
DER EINGANG. Es gibt keine sechste Kachel und es laesst sich auch keine
erzwingen: Wer von aussen `rolle: "modi"` schickt, bekommt wortgleich
dieselbe Antwort wie bei einer erfundenen Rolle. Ein Modi tippt auf
irgendeine vorhandene Kachel -- welche, ist gleichgueltig -- und gibt
seinen Code ein. Der Code allein entscheidet.
Moeglich macht das eine neue Spalte `code_kennung`: ein HMAC ueber den
Code, in Mikrosekunden nachgeschlagen. Zwei naheliegende Wege wurden
verworfen, weil man sie finden kann: ein Merkmal im Code ("M-...") waere
ein sichtbares Kennzeichen auf dem Zettel des Modis, eine eigene Adresse
(/modi.html) eine Seite, die man aufrufen kann. Der Suchschluessel steht
VOR dem gewohnten Weg, nicht dahinter -- ein Rueckfall nach einem
Fehlversuch haette genau die Fehlversuche verlaengert, und daran waere
es zu erkennen gewesen.
Unbedenklich, weil nachgemessen: Ein Code hat 16 Zeichen aus einem
32er-Alphabet, also 80 Bit Zufall. Der Suchschluessel sagt ausserdem nur,
WEN man pruefen soll -- ob der Code stimmt, sagt weiterhin scrypt.
DIE UNSICHTBARKEIT sitzt in verborgeneIds(), also an derselben einen
Stelle wie die Regel fuer den zweiten Admin-Zugang, und nicht in den
rund 170 Abfragen, die Personen lesen. Sie haengt dabei an der ROLLE und
nicht an einer Nummer -- die Schwachstelle, die im Kommentar der alten
Regel offen dasteht (wird Zugang 1 geloescht, rueckt der naechste nach),
kann einer Rolle nicht passieren.
Nach Filipes Entscheidungen: Die Modis sehen sich untereinander (Kapitel
7.2 des Dokuments), VanVan sieht sie mit (Kapitel 3, sie traegt dieselbe
Rolle), Codes gibt Filipe selbst weiter -- kein Einladelink, der in einem
Verlauf landen kann.
ZWEIMAL WAERE DAS WORT "MODI" BEINAHE IN EINER DATEI GELANDET, DIE JEDER
BEKOMMT: in den Rollenlisten von start.js und personen.js. Ein Blick in
den Quelltext haette genuegt. Der Anzeigename kommt jetzt aus
/workspace/api/ich (beschreibt immer nur den Angemeldeten selbst), die
Rollenauswahl aus der Antwort des Servers und nur an die DogFather-Rolle.
GEPRUEFT mit pruef-modi-verborgen.mjs (45 Pruefungen): fuenf Kacheln
fuehren mit dem Modi-Code hinein, ein Manager-Code auf fremder Kachel
weiterhin nicht (sonst waere nebenbei die Rollenpruefung abgeschafft),
und ueber fuenf Schnittstellen sieht ausser DogFather, VanVan und den
Modis niemand etwas -- auch nicht die ZAHL daneben.
Die Gegenprobe steht bewusst ganz unten, weil sie eine Person absichtlich
aus der Regel aushaengt: Weiter oben haette sie jeden Abschnitt danach
verfaelscht. Beim ersten Anlauf stand sie in der Mitte, und prompt tauchte
die Person in einer spaeteren Managerliste auf.
Abschnitt 5 prueft den Weg durch die Anwendung selbst (DogFather legt an,
der Modi meldet sich an). Die Abschnitte davor tragen die Personen von
Hand ein und rechnen den Suchschluessel selbst aus -- damit waere NICHT
bewiesen, dass personAnlegen() ihn im Betrieb schreibt. Ohne ihn kaeme
kein einziger echter Modi herein, und oben waere trotzdem alles gruen.
Die Umstellung der Rollenliste in der Datenbank steht ab jetzt einmal in
rollenRegelUmstellen() statt zum dritten Mal abgeschrieben. Jede Abschrift
waere eine Gelegenheit, eine der vier Absicherungen zu vergessen: Sicherung
vorher, Zaehlung innerhalb der Transaktion, Spaltenliste aus der Tabelle,
Pruefung auf verwaiste Verweise danach.
pruef-personen-formular erwartet jetzt sechs Rollen statt fuenf und prueft
die Liste statt nur die Anzahl -- sechs Karten koennten auch fuenf richtige
und eine doppelte sein. Dass die sechste dort auftaucht, ist gleichzeitig
der Nachweis, dass der Weg ueber den Server funktioniert.
Unveraendert bestanden: pruef-spicy (60), pruef-rollen (97),
pruef-verborgen, pruef-personen-liste, pruef-css-klassen.
Co-Authored-By: Claude Opus 5 <[email protected]>
266 lines
13 KiB
JavaScript
266 lines
13 KiB
JavaScript
/* =====================================================================
|
||
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),
|
||
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.
|
||
|
||
DIE SECHSTE IST 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 === 6, `alle sechs Rollen stehen zur Wahl (${mass.karten})`);
|
||
ok(JSON.stringify([...mass.kartenRollen].sort())
|
||
=== JSON.stringify(["admin", "creator", "manager", "modi", "scout", "spicy"]),
|
||
`und es sind die richtigen (${mass.kartenRollen.join(", ")})`);
|
||
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);
|