Files
dogfather-universe/server/pruef-spicy.mjs
T
DogFatherGitandClaude Opus 5 e1608ee783 Das Ideen-Board -- und fuenf Kacheln, die ins Leere fuehrten
KAPITEL 5.6: "Sammelstelle fuer Content-, Live- und Community-Ideen mit
Priorisierung." Fast nichts davon musste neu gebaut werden: `eintraege`
hat Titel, Text, Status und `dringlichkeit` (hoch/mittel/niedrig) -- und
das IST die Priorisierung. Eine eigene Tabelle daneben waere eine VIERTE
Sichtbarkeitsregel gewesen; genau deren Vervielfaeltigung hat heute
schon ein Leck verursacht.

Der Bereich gehoert niemandem einzeln (`ohneCreatorBezug`) -- eine Idee
gehoert der Runde. KEIN `fuerAlle`: Das waere der naheliegende Griff
gewesen und der falsche, denn es heisst woertlich JEDER. Wer die
Sammlung sieht, entscheidet dieselbe Regel wie ueberall.

DER RISKANTE TEIL WAR DIE DATENBANK. Die erlaubten Bereiche stehen als
CHECK-Regel, und SQLite kann die nicht aendern -- die Tabelle muss neu
gebaut werden. Die alte Umstellung fuer 'agentur' schreibt dafuer den
ganzen Bauplan von Hand ab; im Kommentar dort steht, dass dabei schon
einmal drei Spalten vergessen wurden. Statt einer vierten Abschrift ist
die Fassung von heute Morgen jetzt tabellenunabhaengig
(checkListeErweitern): Spaltenliste aus der Tabelle, Sicherung vorher,
Zaehlung innerhalb der Transaktion.

UND DABEI WAERE EIN STILLER TOTALAUSFALL PASSIERT. Das Muster fuer den
Tabellenkopf stand in einem Template-Literal -- dort verschluckt
JavaScript den Backslash, aus `\s` wird `s`, das Muster hiess
"CREATE TABLEs+..." und traf nie etwas. Die Umstellung haette
SCHWEIGEND nichts getan: kein Fehler, kein Hinweis, nur ein Bereich, den
es nie gegeben haette. Im Quelltext war das nicht zu sehen; gefunden hat
es eine Messung. Jetzt steht dort gar kein Muster mehr -- alles vor der
ersten Klammer IST der Tabellenkopf, und das kann man nicht falsch
maskieren.

Die Pruefung baut deshalb eine ECHTE ALTE Tabelle und laesst die
Umstellung darauf laufen. Eine frische Datenbank bringt den Bereich
schon mit -- die Umstellung liefe gar nicht erst an, und alles waere
gruen, ohne das Riskante angesehen zu haben.

=== DER GROESSERE FUND ===

FUENF VON ZEHN MODI-KACHELN FUEHRTEN INS LEERE. Der Server hat eine
Liste, welche Rolle welche Seite oeffnen darf; bereich.html und
profil.html schlossen 'modi' aus. Vier Bereichs-Kacheln und das eigene
Profil leiteten wortlos auf die Startseite zurueck.

Der Rollen-Rundgang meldete sie trotzdem als "ok", und zu Recht: Eine
Umleitung ist kein Fehler. Die Seite laedt, keine rote Konsole, keine
4xx-Antwort. Sie ist nur eine ANDERE. Das ist eine eigene Fehlerklasse
-- nicht "kaputt", sondern "fuehrt woandershin" -- und sie faellt nur
dem auf, der die Anwendung benutzt und merkt, dass ein Knopf nichts tut.

Beinahe waere meine eigene Pruefung darauf hereingefallen: Sie fand auf
der zurueckgeleiteten Startseite das Wort "Ideen-Board" -- den Text der
KACHEL -- und hielt sie fuer das Board. Jetzt steht die Adresse in der
Bedingung.

DIE SPERRE DAGEGEN GILT AB SOFORT FUER ALLE: pruef-rollen klickt fuer
JEDE Rolle jede Kachel durch, die sie angeboten bekommt, und verlangt,
dass sie dort ankommt. Geprueft wird die Zusage der Startseite, nicht
eine Liste daneben -- eine Liste koennte selbst veralten. 113 -> 243
Pruefungen; der Zuwachs ist genau das.

Er hat im ersten Anlauf zwei weitere Loecher gefunden:

  * "Mein Profil" war die falsche Seite. profil.html ist der
    Creator-Entwicklungsplan und antwortet mit 404, wenn die Person kein
    Creator ist. Die eigene Seite heisst steckbrief.html.
  * content.html stand auf `null` ("jede angemeldete Rolle") -- mit der
    neuen Rolle also auch sie. Die Seite laedt, ihre Schnittstelle gibt
    404. Das ist die Kehrseite von "geschuetzt ist die Regel, nicht die
    Ausnahme": Eine NEUE ROLLE erbt jedes `null` automatisch.

Und ein Modi kommt nur in SEINE Bereiche -- sonst waere er ueber die
Adresszeile in der Agentur-Ablage gelandet. Welche erlaubt sind, wird
aus seinen Kacheln abgeleitet statt danebengeschrieben.

=== KLEINERES, ABER SICHTBARES ===

Ueber dem Board stand "Betreuung" -- die Beschriftung fuer Akten, die
UEBER jemanden gefuehrt werden. Eine Sammelstelle ist das Gegenteil.
Aufgefallen auf dem Bildschirmfoto, wie so oft heute.

Der Farbton der Kachel ist nicht nach Gefuehl gewaehlt: Alle 22
vorhandenen waren belegt, also wurde der Abstand zu jedem ausgerechnet
und der genommen, der sich am deutlichsten unterscheidet, ohne grau zu
wirken (#5f8a9f, Abstand 127).

BEINAHE HAETTE ICH EIN LOCH REPARIERT, DAS ES NICHT GIBT: Meine Pruefung
meldete, die Suche verrate die Ideen an Spicy Media. Tatsaechlich fand
sie null Treffer -- die Pruefung hatte ihren EIGENEN Suchbegriff
wiedergefunden, weil die Antwort ihn im Feld `frage` zurueckspiegelt.
Gezaehlt wird jetzt, was gefunden wurde.

pruef-spicy erwartete den alten Wortlaut der Umstellungsmeldung. Sie
nennt jetzt Tabelle und Spalte statt "Rolle"; geprueft wird der Sinn,
nicht der Satz.

GEPRUEFT: pruef-modi-ideen (30, neu), pruef-rollen (243 statt 113),
pruef-modi-verborgen (75), pruef-modi-katalog (29), pruef-spicy (60),
pruef-css-klassen, pruef-start-ansicht.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 02:19:06 +02:00

568 lines
30 KiB
JavaScript

/* =====================================================================
DIE ROLLE "SPICY MEDIA"
Wunsch Filipe (07.09.2026): "eine neue rolle ... soll die gleichen
rechte haben wie dogfather ausser, bei automationen sollen die nicht
sehen und die kategorie personen und zugaenge sollen die nur leute
hinzufuegen koennen ... aber die sollen meine privaten chats und so
nicht sehen."
---------------------------------------------------------------------
ZWEI DINGE WERDEN HIER GEPRUEFT, UND DAS ZWEITE IST DAS WICHTIGERE
1. DER UMBAU DER PERSONENTABELLE. Eine Rolle ist ein erlaubter Wert in
einer Spalte, und der steckt in einem CHECK. Ein CHECK laesst sich
nicht aendern -- die Tabelle muss neu gebaut werden. Auf `personen`
zeigen ZWEIUNDFUENFZIG Fremdschluessel; jede Sitzung, jede Aufgabe,
jeder Termin haengt daran. Deshalb wird der Weg hier nicht "im
Prinzip" geprueft, sondern WIRKLICH GEGANGEN: Die Pruefung baut
eine Datenbank im alten Stand nach, fuellt sie mit Personen UND
abhaengigen Zeilen, laesst die echte Anwendung darueberlaufen und
zaehlt danach jede Zeile und jedes Feld nach.
2. DIE GRENZEN DER ROLLE. Zu jedem "darf" steht das "darf nicht"
daneben. Eine Rolle, von der nur geprueft ist, was sie kann, ist
keine Rolle, sondern eine Vermutung.
Aufruf: node server/pruef-spicy.mjs
===================================================================== */
import { mkdtempSync, rmSync, existsSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname, basename } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
import { DatabaseSync } from "node:sqlite";
import { scryptSync, randomBytes } from "node:crypto";
import { notbremse } from "./helfer-notbremse.mjs";
notbremse(300_000, "die Spicy-Pruefung");
const HIER = dirname(fileURLToPath(import.meta.url));
const { portMussFreiSein } = await import("./helfer-port.mjs");
const PORT = 4351;
await portMussFreiSein(PORT, "die Spicy-Pruefung");
const BASIS = `http://127.0.0.1:${PORT}`;
const ordner = mkdtempSync(join(tmpdir(), "ws-spicy-"));
const DB = join(ordner, "workspace.db");
let fehler = 0, gemacht = 0;
const ok = (b, t) => { gemacht++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
let kind = null;
async function starten() {
kind = spawn(process.execPath, [join(HIER, "index.js")], {
env: { ...process.env, WORKSPACE_DB: DB, PORT: String(PORT),
SITE_ACCESS_SECRET: "lokaler-test",
SITE_PUBLIC_LAUNCH_AT: "2020-01-01T00:00:00+01:00" },
stdio: ["ignore", "pipe", "pipe"] });
let aus = "";
kind.stdout.on("data", (d) => { aus += d; });
kind.stderr.on("data", (d) => { aus += d; });
for (let i = 0; i < 100; i++) {
await new Promise((r) => setTimeout(r, 250));
if (kind.exitCode !== null) break;
try { const a = await fetch(BASIS + "/workspace/", { redirect: "manual" });
if (a.status < 500) return () => aus; } catch { /* noch nicht oben */ }
}
throw new Error("Server startete nicht:\n" + aus.slice(-1500));
}
async function stoppen() {
if (!kind) return;
kind.kill(); kind = null;
await new Promise((r) => setTimeout(r, 700));
}
/* =======================================================================
1. Eine Datenbank im ALTEN Stand -- mit Personen und allem, was daran
haengt. Ohne die abhaengigen Zeilen pruefte der Umbau nur eine
Tabelle statt eines Netzes.
======================================================================= */
console.log("=== Der alte Stand ===");
await starten();
await stoppen();
const jetzt = new Date().toISOString();
const anlegen = (d, name, rolle, code) => {
const salz = randomBytes(16).toString("hex");
const hash = scryptSync(code, salz, 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, salz, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
};
let idDogi, idMax, idLuna, spaltenVorher, personenVorher;
{
const d = new DatabaseSync(DB);
/* Die Tabelle auf den alten CHECK zuruecksetzen -- WOERTLICH so, wie
sie vor heute aussah, aber mit allen Spalten, die inzwischen
dazugekommen sind. Genau das ist der Fall, der gefaehrlich ist. */
d.exec("PRAGMA foreign_keys = OFF");
const alt = d.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='personen'").get().sql;
const zurueck = alt
/* Auch hier muss `IF NOT EXISTS` ins Muster -- sonst waere der
"alte Stand" gar keiner: Die Ersetzung liefe ins Leere, `CREATE
TABLE IF NOT EXISTS personen` waere wegen des vorhandenen Tisches
ein Leerlauf, und die Pruefung testete den heutigen Stand gegen
sich selbst. */
.replace(/CREATE TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?personen["'`]?/i,
"CREATE TABLE personen_alt")
.replace(/rolle\s+IN\s*\([^)]*\)/i, "rolle IN ('admin','manager','scout','creator')");
/* Wenn der Rueckbau nicht greift, ist alles danach wertlos -- dann
prueft diese Datei den heutigen Stand gegen sich selbst. Deshalb
hier abbrechen statt weitermachen. */
const regelDanach = zurueck.match(/rolle\s+IN\s*\(([^)]*)\)/i)?.[1] || "";
if (!zurueck.includes("personen_alt") || regelDanach.includes("'spicy'")) {
console.log(" FEHL der alte Stand liess sich nicht nachbauen -- Muster passt nicht.");
process.exit(1);
}
d.exec("BEGIN");
d.exec(zurueck);
const sp = d.prepare("PRAGMA table_info(personen)").all().map((z) => `"${z.name}"`).join(", ");
d.exec(`INSERT INTO personen_alt (${sp}) SELECT ${sp} FROM personen;`);
d.exec("DROP TABLE personen;");
d.exec("ALTER TABLE personen_alt RENAME TO personen;");
d.exec("COMMIT");
d.exec("PRAGMA foreign_keys = ON");
idDogi = anlegen(d, "Filipe", "admin", "CODE-DOGI-0001");
idMax = anlegen(d, "Max", "manager", "CODE-MAX-00001");
idLuna = anlegen(d, "Luna", "creator", "CODE-LUNA-0001");
/* Ein Profilbild und ein Feld aus einer SPAETER ergaenzten Spalte --
genau die verliert man beim Abschreiben einer Spaltenliste. */
d.prepare("UPDATE personen SET bild = ?, ueber_mich = ?, tiktok = ? WHERE id = ?")
.run("abcdef0123456789abcdef0123456789.jpg", "Ich streame seit 2024.", "luna_live", idLuna);
/* Abhaengige Zeilen aus verschiedenen Tabellen. */
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)")
.run(idLuna, idMax, jetzt);
d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, creator_id, erstellt, erstellt_von)
VALUES ('technik','problem','Ton bricht ab',?,?,?,?)`)
.run(jetzt.slice(0, 10), idLuna, jetzt, idMax);
spaltenVorher = d.prepare("PRAGMA table_info(personen)").all().map((z) => z.name);
personenVorher = d.prepare("SELECT COUNT(*) AS n FROM personen").get().n;
/* GEGENPROBE: Der ALTE Stand muss 'spicy' wirklich ablehnen -- sonst
gaebe es nichts umzustellen, und alles danach bewiese nichts. */
let abgelehnt = false;
try {
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,'spicy','x','y',1,1,?)")
.run("Darf noch nicht", jetzt);
} catch { abgelehnt = true; }
d.close();
ok(abgelehnt, "der alte Stand lehnt die Rolle 'spicy' wirklich ab");
ok(personenVorher === 3, `${personenVorher} Personen und abhaengige Zeilen angelegt`);
ok(spaltenVorher.includes("bild") && spaltenVorher.includes("tiktok"),
`die Tabelle hat ${spaltenVorher.length} Spalten, auch die spaeter ergaenzten`);
}
/* =======================================================================
2. Die Anwendung laeuft darueber
======================================================================= */
console.log("\n=== Die Umstellung ===");
const ausgabe = await starten();
/* Die Umstellung laeuft beim ersten Datenbankzugriff, nicht beim
Zuhoeren am Anschluss -- ihre Meldung kann also erst kurz nach dem
Start in der Ausgabe stehen. Eine Anfrage stellen und kurz warten. */
await fetch(BASIS + '/workspace/', { redirect: 'manual' }).catch(() => {});
await new Promise((r) => setTimeout(r, 900));
console.log("--- Serverausgabe ---");
for (const z of ausgabe().split(String.fromCharCode(10))) if (z.includes("workspace]")) console.log(" " + z);
{
/* DER WORTLAUT HAT SICH GEAENDERT (10.09.2026), die Sache nicht.
Die Umstellung gilt seither fuer JEDE Tabelle mit einer CHECK-Liste
(auch fuer eintraege.bereich), deshalb nennt die Meldung Tabelle
und Spalte statt "Rolle". Geprueft wird jetzt, was gemeint ist --
dass 'spicy' freigeschaltet wurde --, und nicht der genaue Satz:
Sonst bricht diese Zeile beim naechsten Feinschliff wieder, ohne
dass irgendetwas kaputt waere. */
ok(/'spicy'.*freigeschaltet/.test(ausgabe()),
"die Umstellung meldet sich im Protokoll");
ok(/Sicherung vor der Umstellung/.test(ausgabe()),
"und hat vorher eine Sicherung geschrieben");
const sicherungen = readdirSync(ordner).filter((n) => n.includes("vor-spicy"));
ok(sicherungen.length === 1, `die Sicherungsdatei liegt da (${sicherungen[0] || "keine"})`);
}
{
const d = new DatabaseSync(DB, { readOnly: true });
const spalten = d.prepare("PRAGMA table_info(personen)").all().map((z) => z.name);
const fehlend = spaltenVorher.filter((n) => !spalten.includes(n));
ok(fehlend.length === 0,
fehlend.length ? `nach der Umstellung fehlen Spalten: ${fehlend.join(", ")}`
: `alle ${spalten.length} Spalten sind noch da`);
const n = d.prepare("SELECT COUNT(*) AS n FROM personen").get().n;
ok(n === personenVorher, `alle ${n} Personen sind noch da (vorher ${personenVorher})`);
const luna = d.prepare("SELECT * FROM personen WHERE id = ?").get(idLuna);
ok(luna?.bild === "abcdef0123456789abcdef0123456789.jpg",
`das Profilbild hat den Umbau ueberlebt (${luna?.bild ? "ja" : "WEG"})`);
ok(luna?.ueber_mich === "Ich streame seit 2024." && luna?.tiktok === "luna_live",
"auch die spaeter ergaenzten Textspalten");
const b = d.prepare("SELECT betreuer_id FROM betreuung WHERE creator_id = ?").get(idLuna);
ok(b?.betreuer_id === idMax, `die Betreuung zeigt noch auf Max (${b?.betreuer_id})`);
const e = d.prepare("SELECT creator_id FROM eintraege WHERE titel = 'Ton bricht ab'").get();
ok(e?.creator_id === idLuna, `der Technik-Eintrag noch auf Luna (${e?.creator_id})`);
const kaputt = d.prepare("PRAGMA foreign_key_check").all();
ok(kaputt.length === 0, `keine verwaisten Verweise (${kaputt.length})`);
/* GEGENPROBE, DIE WICHTIGSTE: Die Schranke muss noch BEISSEN. Eine
Umstellung, die nebenbei den CHECK entfernt, sieht aus wie ein
Erfolg und ist der schlimmere Ausgang. */
d.close();
const s = new DatabaseSync(DB);
let erfunden = false;
try {
s.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,'quatsch','x','y',1,1,?)")
.run("Erfundene Rolle", jetzt);
} catch { erfunden = true; }
ok(erfunden, "die Schranke beisst noch -- eine erfundene Rolle wird abgelehnt");
const idSpicy = anlegen(s, "Agentur", "spicy", "CODE-SPICY-001");
/* EIN ZWEITER ADMIN-ZUGANG. Ohne ihn waere die Regel "Spicy Media
sieht DogFather, aber nicht den zweiten Admin" gar nicht pruefbar
-- bei einem einzigen Admin ist jede Antwort richtig. Am echten
System gibt es zwei (id 1 "Dogfather", id 4 "VanVan"). */
anlegen(s, "VanVan", "admin", "CODE-VAN-000001");
ok(Number.isInteger(idSpicy) && idSpicy > 0, "'spicy' laesst sich jetzt anlegen");
s.close();
}
/* =======================================================================
3. Was die Rolle darf -- und was nicht
======================================================================= */
console.log("\n=== Rechte ===");
const anmelden = async (rolle, code) => {
const a = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle, code }) });
const keks = (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; ");
if (!keks) throw new Error(`Anmeldung ${rolle} fehlgeschlagen (${a.status})`);
return keks;
};
const keksSpicy = await anmelden("spicy", "CODE-SPICY-001");
const keksDogi = await anmelden("admin", "CODE-DOGI-0001");
const keksMax = await anmelden("manager", "CODE-MAX-00001");
const ruf = async (art, weg, keks, koerper) => {
const a = await fetch(BASIS + weg, { method: art,
headers: { Cookie: keks, "Content-Type": "application/json", Origin: BASIS },
body: koerper ? JSON.stringify(koerper) : undefined });
let daten = null; try { daten = await a.json(); } catch { /* leer erlaubt */ }
return { status: a.status, daten };
};
{
const { status, daten } = await ruf("GET", "/workspace/api/ich", keksSpicy);
ok(status === 200 && daten?.rolle === "spicy", `die Anmeldung klappt (${daten?.rolle})`);
ok(daten?.rolle_name === "Spicy Media", `und heisst in der Oberflaeche "${daten?.rolle_name}"`);
}
{
/* SIEHT ALLES: alle Personen, alle Bereiche. */
const { daten } = await ruf("GET", "/workspace/api/personen", keksSpicy);
const namen = (daten?.personen || []).map((p) => p.name).sort();
ok(namen.includes("Luna") && namen.includes("Max") && namen.includes("Filipe"),
`sie sieht alle: ${namen.join(", ")}`);
}
{
const { status } = await ruf("GET", "/workspace/api/uebersicht/creator", keksSpicy);
ok(status === 200, `das Dashboard steht ihr offen (HTTP ${status})`);
}
{
/* NICHT: Automationen. */
const a = await fetch(BASIS + "/workspace/automation.html", {
headers: { Cookie: keksSpicy }, redirect: "manual" });
ok(a.status === 302 || a.status === 403 || a.status === 404,
`die Automationen-Seite bleibt zu (HTTP ${a.status})`);
const { status } = await ruf("GET", "/workspace/api/sicherung/zustand", keksSpicy);
ok(status === 404, `und die Sicherungs-Schnittstelle auch (HTTP ${status})`);
}
{
/* NICHT: die Personenverwaltung. Anlegen ja, alles andere nein. */
const { status, daten } = await ruf("GET", "/workspace/api/verwaltung/personen", keksSpicy);
/* AM 07.09.2026 GETEILT: Die LISTE ist fuer sie offen, alles andere
nicht. Hier stand vorher ein einziges "bleibt zu" -- das ist seit
Filipes Wunsch "spicy soll genau das sehen koennen" zu grob. Aus
einer Aussage werden drei, und die dritte ist die wichtigste:
Ueberblick ist nicht Verwaltung. */
ok(status === 200, `die Personenliste ist fuer sie offen (HTTP ${status})`);
/* AM 07.09.2026 PRAEZISIERT. Erst hiess es "DogFather steht nicht
darin", dann: "die rolle spicy soll auch die rolle dogfather sehen,
aber nur dogfather und nicht vanvan."
Beide Zugaenge tragen dieselbe Rolle `admin`; unterschieden werden
sie ueber das ALTER -- DogFather ist der erste Zugang des Hauses,
also die kleinste Nummer. Genau das wird hier gemessen, und der
zweite Admin ist der Prueffall: Ohne ihn waere jede Antwort
richtig. */
const admins = (daten?.personen || []).filter((x) => x.rolle === "admin");
ok(admins.length === 1,
`genau EIN Admin-Zugang steht in ihrer Liste (${admins.length})`);
ok(admins[0]?.id === idDogi,
`und zwar der erste, DogFather (${admins[0]?.name || "keiner"})`);
ok(!(daten?.personen || []).some((x) => x.name === "VanVan"),
"der zweite Admin-Zugang nicht");
const prot = await ruf("GET", "/workspace/api/verwaltung/protokoll?anzahl=5", keksSpicy);
ok(prot.status === 404, `das Protokoll bleibt zu (HTTP ${prot.status})`);
const loeschen = await ruf("DELETE", `/workspace/api/verwaltung/personen/${idLuna}`, keksSpicy);
ok(loeschen.status === 404, `loeschen geht nicht (HTTP ${loeschen.status})`);
const code = await ruf("POST", `/workspace/api/verwaltung/personen/${idLuna}/code`, keksSpicy);
ok(code.status === 404, `einen Code neu setzen auch nicht (HTTP ${code.status})`);
}
{
/* ABER: Leute anlegen -- Creator und Manager. */
const c = await ruf("POST", "/workspace/api/creator-anlegen", keksSpicy, { name: "NeuerCreator" });
ok(c.status === 201 && c.daten?.rolle === "creator",
`sie legt einen Creator an (HTTP ${c.status}, ${c.daten?.rolle})`);
const m = await ruf("POST", "/workspace/api/manager-anlegen", keksSpicy, { name: "NeuerManager" });
ok(m.status === 201 && m.daten?.rolle === "manager",
`und einen Manager (HTTP ${m.status}, ${m.daten?.rolle})`);
}
{
/* GEGENPROBE: Ein Manager kann KEINEN Manager anlegen -- sonst waere
die zweite Tuer nur Zierde. */
const { status } = await ruf("POST", "/workspace/api/manager-anlegen", keksMax, { name: "VonMax" });
ok(status === 404, `ein Manager kommt durch diese Tuer NICHT (HTTP ${status})`);
}
{
/* NICHT: fremde Chats.
Der Chat haengt ausschliesslich an der Teilnehmerliste -- Spicy
Media ist nirgends Teilnehmerin, also sieht sie nichts. Geprueft
wird mit einem ECHTEN Gespraech zwischen DogFather und Max.
DER AUFBAU MUSS GELINGEN, SONST BEWEIST DER REST NICHTS. Beim
ersten Anlauf stand hier ein falscher Weg (/chat/raum statt
/chat/direkt). Das Gespraech entstand gar nicht -- und "Spicy Media
sieht 0 Gespraeche" war trotzdem gruen, weil es ueberhaupt keine
gab. Genau die Sorte gruener Haken, die nichts geprueft hat.
Deshalb ist das Anlegen jetzt selbst eine Pruefung, und die
Raumnummer wird nachgehalten. */
const raum = await ruf("POST", "/workspace/api/chat/direkt", keksDogi, { mit: idMax });
const raumId = raum.daten?.raum_id;
ok(raum.status === 200 && Number.isInteger(raumId),
`DogFather und Max haben ein Gespraech (HTTP ${raum.status}, Raum ${raumId})`);
const nachricht = await ruf("POST", `/workspace/api/chat/raeume/${raumId}/nachrichten`,
keksDogi, { text: "Streng geheim." });
ok(nachricht.status === 200 || nachricht.status === 201,
`und eine Nachricht darin (HTTP ${nachricht.status})`);
/* GEGENPROBE: Max SIEHT das Gespraech -- sonst pruefte die Zeile
darunter nur, dass die Liste immer leer ist. */
const beiMax = await ruf("GET", "/workspace/api/chat/raeume", keksMax);
ok((beiMax.daten?.raeume || []).length === 1,
`Max sieht es (${(beiMax.daten?.raeume || []).length})`);
const meine = await ruf("GET", "/workspace/api/chat/raeume", keksSpicy);
const raeume = meine.daten?.raeume || [];
ok(raeume.length === 0, `Spicy Media sieht 0 Gespraeche (${raeume.length})`);
const rein = await ruf("GET", `/workspace/api/chat/raeume/${raumId}/nachrichten`, keksSpicy);
ok(rein.status === 403 || rein.status === 404,
`und kommt auch mit der Raumnummer nicht hinein (HTTP ${rein.status})`);
}
{
/* ===================================================================
WAS DOGFATHER GEHOERT, SIEHT SPICY MEDIA NICHT (07.09.2026)
Filipe: "wieso sieht die spicy rolle meine daten die ich habe und
speichere, meins sollen die nicht sehen von der rolle dogfather."
Beim ersten Anlauf bekam Spicy Media dieselbe Regel wie DogFather
(`1=1`) -- damit stimmte der Ueberblick und nebenbei standen seine
eigenen Termine, Aufgaben, Dateien und Eintraege mit drin.
Geprueft wird mit ZWEI Datensaetzen je Art: einem von DogFather und
einem von Max. Mit nur einem waere jedes Ergebnis mehrdeutig -- eine
leere Liste koennte auch heissen, dass die Regel ALLES ausblendet.
Genau das ist der Unterschied zwischen "sieht das Richtige nicht"
und "sieht gar nichts". */
/* ORTSZEIT, nicht UTC. `toISOString().slice(0,10)` liegt zwischen
Mitternacht und zwei Uhr einen Tag daneben -- die Pruefung waere
dann nachts rot, ohne dass sich etwas geaendert haette. Gefunden
von pruef-struktur, das genau danach sucht. */
const jetztHier = new Date();
const zz = (n) => String(n).padStart(2, "0");
const heute = `${jetztHier.getFullYear()}-${zz(jetztHier.getMonth() + 1)}-${zz(jetztHier.getDate())}`;
await ruf("POST", "/workspace/api/bereich/technik", keksDogi,
{ art: "problem", titel: "DogFathers Notiz", datum: heute });
await ruf("POST", "/workspace/api/bereich/technik", keksMax,
{ art: "problem", titel: "Max seine Notiz", datum: heute, creator_id: idLuna });
const { daten } = await ruf("GET", "/workspace/api/bereich/technik", keksSpicy);
const titel = (daten?.eintraege || []).map((e) => e.titel);
ok(!titel.includes("DogFathers Notiz"),
`DogFathers Eintrag ist fuer Spicy Media unsichtbar (sie sieht: ${titel.join(", ") || "nichts"})`);
ok(titel.includes("Max seine Notiz"),
"Max seiner steht aber da -- die Regel blendet nicht einfach alles aus");
/* Dasselbe fuer Aufgaben und Termine -- drei Module, drei Wege, und
jeder haette einzeln vergessen werden koennen. */
await ruf("POST", "/workspace/api/aufgaben", keksDogi, { titel: "DogFathers Aufgabe" });
await ruf("POST", "/workspace/api/aufgaben", keksMax,
{ titel: "Max seine Aufgabe", creator_id: idLuna });
const auf = await ruf("GET", "/workspace/api/aufgaben", keksSpicy);
const aTitel = (auf.daten?.aufgaben || []).map((a) => a.titel);
ok(!aTitel.includes("DogFathers Aufgabe"),
`auch seine Aufgabe nicht (sie sieht: ${aTitel.join(", ") || "nichts"})`);
ok(aTitel.includes("Max seine Aufgabe"), "Max seine schon");
/* Der Kalender folgt seit dem 07.09.2026 einer ANDEREN Regel als
Bereiche und Aufgaben, und das ist Absicht (Begruendung steht bei
termineSichtbar in workspace.js): Dort entscheidet die Rolle, hier
entscheidet allein die BETEILIGUNG -- und zwar fuer jeden gleich.
Spicy Media sieht deshalb WEDER DogFathers teilnehmerlosen Termin
NOCH Max' Verabredung mit Luna.
Weil hier zweimal "nicht" steht, braucht es die Gegenprobe
darunter zwingend: Sonst waere derselbe gruene Haken auch bei
einem Kalender zu haben, der gar nichts mehr liefert. */
await ruf("POST", "/workspace/api/termine", keksDogi,
{ titel: "DogFathers Team-Termin", art: "termin", beginn: heute + "T20:00", dauer_min: 30 });
await ruf("POST", "/workspace/api/termine", keksMax,
{ titel: "Max sein Termin", art: "termin", beginn: heute + "T21:00", dauer_min: 30,
creator_id: idLuna });
const term = await ruf("GET", `/workspace/api/termine?von=${heute}&bis=${heute}`, keksSpicy);
const tTitel = (term.daten?.termine || []).map((t) => t.titel);
ok(!tTitel.includes("DogFathers Team-Termin"),
`auch seinen teilnehmerlosen Termin nicht (${tTitel.join(", ") || "nichts"})`);
ok(!tTitel.includes("Max sein Termin"),
"und Max' Verabredung mit Luna auch nicht -- beide gehen sie nichts an");
/* GEGENPROBE ZUM KALENDER: Ein Termin, bei dem sie eingetragen IST,
kommt sehr wohl an. Erst diese Zeile macht die beiden "nicht"
darueber zu einer Aussage. */
/* Ihre Nummer wird hier erfragt und nicht aus Block 2 mitgenommen --
die steht dort in einem eigenen Gueltigkeitsbereich, und eine
Variable ueber drei Bloecke zu schleifen, nur um eine Zahl zu
haben, ist genau die Art Verbindung, die spaeter jemand loest. */
const ichSpicy = await ruf("GET", "/workspace/api/ich", keksSpicy);
const nrSpicy = ichSpicy.daten?.id;
ok(Number.isInteger(nrSpicy) && nrSpicy > 0, `ihre Nummer ist lesbar (${nrSpicy})`);
const mitIhr = await ruf("POST", "/workspace/api/termine", keksDogi,
{ titel: "Abstimmung mit Spicy", art: "termin", beginn: heute + "T22:00",
dauer_min: 30, teilnehmer: [nrSpicy] });
ok(mitIhr.status === 201, `ein Termin MIT ihr angelegt (${mitIhr.status})`);
const term2 = await ruf("GET", `/workspace/api/termine?von=${heute}&bis=${heute}`, keksSpicy);
ok((term2.daten?.termine || []).some((t) => t.titel === "Abstimmung mit Spicy"),
"den sieht sie -- markiert werden ist der Weg, jemanden zu beteiligen");
/* GEGENPROBE: DogFather selbst sieht weiterhin BEIDE. Ohne sie
bewiesen die Zeilen oben nur, dass irgendetwas gefiltert wird. */
const beiDogi = await ruf("GET", "/workspace/api/bereich/technik", keksDogi);
const dTitel = (beiDogi.daten?.eintraege || []).map((e) => e.titel);
ok(dTitel.includes("DogFathers Notiz") && dTitel.includes("Max seine Notiz"),
`DogFather sieht weiterhin beide (${dTitel.length})`);
}
{
/* DIE ZAHLEN SIEHT SIE NIRGENDS (07.09.2026).
Zuerst waren nur Kachel und Seite geschlossen -- die Creator-Zahlen
standen damit weiterhin im Dashboard, weil das sie ueber eine
eigene Schnittstelle holt. Filipe hat entschieden, dass auch das
weg soll.
Geprueft wird die SCHNITTSTELLE und nicht die Oberflaeche: Eine
ausgeblendete Zeile ist keine Sperre. Und mit Gegenprobe, sonst
bewiese die Zeile nur, dass es den Weg gar nicht gibt. */
const beiIhr = await ruf("GET", "/workspace/api/leistung", keksSpicy);
ok(beiIhr.status === 404, `die Zahlen antworten ihr mit 404 (${beiIhr.status})`);
const beiIhm = await ruf("GET", "/workspace/api/leistung", keksDogi);
ok(beiIhm.status === 200, `DogFather bekommt sie weiterhin (${beiIhm.status})`);
/* UND DAS DASHBOARD BLEIBT FUER SIE STEHEN. Das ist der eigentliche
Prueffall: Die Uebersicht holt die Zahlen in einer zusaetzlichen
Abfrage. Faengt sie deren Fehlschlag nicht ab, saehe Spicy Media
statt der Karten eine leere Seite -- eine Sperre, die die halbe
Anwendung mitnimmt, waere schlimmer als keine. */
const dash = await ruf("GET", "/workspace/api/uebersicht", keksSpicy);
ok(dash.status === 200, `ihre Uebersicht kommt trotzdem (${dash.status})`);
/* IM CHAT IST SIE FUER JEDEN ERREICHBAR (07.09.2026).
Filipe: "im chat muss die spicy rolle und die dogfather rolle fuer
jeden zugaenglich sein." Gemessen aus der Sicht eines CREATORS --
der steht am Ende jeder Kette und ist damit der Prueffall: Wer bei
ihm ankommt, kommt ueberall an.
Mit Gegenprobe: Seine Liste ist nicht einfach jeder. Waere sie das,
bewiese die Zeile darueber nichts. */
const keksLuna = await anmelden("creator", "CODE-LUNA-0001");
const partner = await ruf("GET", "/workspace/api/chat/partner", keksLuna);
const rollenDrin = (partner.daten?.personen || []).map((x) => x.rolle);
ok(rollenDrin.includes("spicy"),
`ein Creator erreicht Spicy Media im Chat (${rollenDrin.join(", ") || "niemanden"})`);
ok(rollenDrin.includes("admin"), "und DogFather ebenfalls");
ok(!rollenDrin.includes("creator"),
"einen anderen Creator dagegen nicht -- die Liste ist nicht einfach jeder");
/* Und DogFather bleibt der Einzige mit dem letzten Wort. */
const { status } = await ruf("GET", "/workspace/api/verwaltung/personen", keksDogi);
ok(status === 200, `DogFather kommt weiterhin in die Verwaltung (HTTP ${status})`);
}
/* =======================================================================
4. Die Anmeldeseite kennt die Rolle
======================================================================= */
console.log("\n=== Die Anmeldeseite ===");
{
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 950 } });
const seite = await ctx.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
const rollen = await seite.$$eval(".rolle", (k) => k.map((x) => x.dataset.rolle));
ok(rollen[0] === "spicy", `sie steht ganz oben (${rollen.join(", ")})`);
await seite.click('.rolle[data-rolle="spicy"]');
await seite.fill("#code", "CODE-SPICY-001");
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 25000 });
await seite.waitForTimeout(1500);
const kacheln = await seite.$$eval(".kachel__name", (k) => k.map((x) => x.textContent.trim()));
ok(kacheln.includes("Personen & Zugänge"),
`sie sieht die Personen-Kachel (${kacheln.length} Kacheln)`);
ok(!kacheln.includes("Automationen"), "und die Automationen-Kachel NICHT");
/* DIE PERSONENLISTE: Spicy Media sieht alle Abschnitte AUSSER
DogFather (07.09.2026, Wunsch Filipe). Geprueft an den
Ueberschriften, die wirklich auf dem Bildschirm stehen -- nicht an
der Liste im Quelltext, die sie erzeugt. */
await seite.goto(BASIS + "/workspace/personen.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(1200);
const abschnitteSpicy = await seite.$$eval(".gruppe__name, .gruppe__kopf",
(k) => k.map((x) => x.textContent.trim()).filter(Boolean));
const alsText = abschnitteSpicy.join(" | ");
ok(/DogFather/.test(alsText),
`bei ihr steht der Abschnitt DogFather (${alsText.slice(0, 70) || "keine Abschnitte"})`);
ok(/Manager|Creator|Scout/.test(alsText),
"und die uebrigen Abschnitte ebenfalls -- die Liste ist nicht leer");
await ctx.close();
/* GEGENPROBE BEI DOGFATHER: Bei ihm steht der Abschnitt "Spicy Media"
sehr wohl -- genau das hat Filipe vermisst. Ohne diese Zeile
bewiese die Zeile darueber nur, dass die Liste leer ist. */
const ctx2 = await browser.newContext({ viewport: { width: 1440, height: 950 } });
const seite2 = await ctx2.newPage();
await seite2.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await seite2.click('.rolle[data-rolle="admin"]');
await seite2.fill("#code", "CODE-DOGI-0001");
await seite2.click("button[type=submit]");
await seite2.waitForURL("**/start.html", { timeout: 25000 });
await seite2.goto(BASIS + "/workspace/personen.html", { waitUntil: "networkidle" });
await seite2.waitForTimeout(1400);
const abschnitteDogi = (await seite2.$$eval(".gruppe__name, .gruppe__kopf",
(k) => k.map((x) => x.textContent.trim()).filter(Boolean))).join(" | ");
ok(/Spicy Media/.test(abschnitteDogi),
`bei DogFather steht der Abschnitt "Spicy Media" (${abschnitteDogi.slice(0, 80)})`);
ok(/DogFather/.test(abschnitteDogi), "und seiner ebenfalls");
await ctx2.close();
await browser.close();
}
await stoppen();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Zugabe */ }
console.log(`\n${gemacht} Pruefungen`);
console.log(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
process.exit(fehler ? 1 : 0);