Files
dogfather-universe/server/pruef-team-ampel.mjs
T
DogFatherGitandClaude Opus 5 ea8ceaa539 Das Farbhaus von Team Dogi lag oeffentlich im Netz
Ausgeloest von pruef-modi-wortleck, die seit Tagen rot war: "24
Fundstellen -- der verborgene Zugang waere damit auffindbar." Die
erste Frage war, ob sie recht hat. Gemessen: ja, und schlimmer als
gedacht.

WER DIESE DATEIEN BEKOMMT: Nicht nur Kollegen. `curl` ohne jeden Keks
auf workspace.dogfather-universe.com liefert die Skripte und
Stilvorlagen des Arbeitsplatzes mit HTTP 200 -- fuer jeden im Netz
lesbar.

DARUNTER crew-haus.css, 27 806 Byte, das ganze Farbhaus von Team Dogi
samt seiner Kommentare. In deren Kopf stand seit dem 10.09. woertlich
"Diese Datei wird NUR auf crew.dogfather-universe.com ausgeliefert."
Das war eine Behauptung, keine Tatsache: Die Weiche biegt `haus.css`
auf diesen Namen um, wer den Namen direkt nannte, ging an ihr vorbei.
Ein Kommentar, der eine Absicht beschreibt, erfuellt sie nicht.

Zwei Stellen im Haus haben sich damit widersprochen -- die Datei sagte
"nur auf crew.", pruef-crew-adresse verlangte ausdruecklich das
Gegenteil ("sie ist kein Geheimnis"). Durchgesetzt war die offene
Fassung. Filipe hat am 21.09. entschieden: zusperren. Die alte
Begruendung dagegen war die Sorge, eine Sperre koenne die Pruefung
blind machen -- die ist jetzt beantwortet statt vermieden: Jeder
Eintrag der Tafel wird in BEIDE Richtungen gemessen, und dass die
Umbiegung von `haus.css` auf crew. weiterhin greift, ebenfalls.

DIE ADRESSE WAR DAS EIGENTLICHE GEHEIMNIS, nicht das Wort. Die
Rollennamen stehen auf der Zugangswand von Team Dogi ohnehin offen --
so gewollt. WO sie liegt, stand dagegen im Klartext in gate.css,
gate.js und crew-haus.css, alle drei oeffentlich abrufbar. Jetzt steht
sie in keiner ausgelieferten Datei mehr.

DIE PRUEFUNG SELBST HATTE DREI MAENGEL:

  1. Sie kannte die Mehrzahl nicht. `\bmodi\b` fand "ein Modi", nicht
     "die Modis" -- 23 weitere Stellen, darunter der Satz "Fuer die
     rechte Hand und die Modis gibt es dort den stillen Zugang". Also
     genau die Auskunft, die sie verhindern soll, in der Form, die sie
     nicht sah.
  2. Ihre Ausnahmeliste war abgeschrieben. Sie nannte eine Datei; die
     zweite, die der Server auf crew. begrenzt, haette sie nie
     erfahren. Ausgenommen ist jetzt genau das, was der Server auch
     durchsetzt (GEHOERT_ZU_ADRESSE) -- kein Muster, sondern die
     Durchsetzung selbst.
  3. Sie mahnte 45 Kommentare an. Filipes Entscheidung: in Kommentaren
     ja, in Code und sichtbaren Texten nein. Rot, das immer da ist,
     wird ueberlesen -- und dann faengt sie auch den echten Fall nicht.

Dafuer neu: helfer-ohne-kommentar.mjs, ein Zerleger, der Kommentare
zeichengenau durch Leerzeichen ersetzt, ohne Zeichenketten,
Vorlagen oder Suchmuster zu beschaedigen. KEIN Suchausdruck -- genau
daran ist am 20.09. die Portumstellung gescheitert, mit gueltigem
JavaScript ohne das Feld `port`. Dreizehn Proben in beide Richtungen
beweisen, dass er weder zu viel noch zu wenig wegnimmt; ein Zerleger,
der zu viel nimmt, macht die ganze Pruefung STILL gruen.

Von 45 blieben damit drei echte, alle behoben:
  - daten.modis  -> daten.leute (Feldname in der Auskunft, also Code)
  - zwei Saetze, die Menschen lesen, auf "die Moderation" umgestellt --
    fuer jemanden, der neu im Treff ist, sogar klarer

Nebenbei, beim selben Durchgang gefunden und geschlossen:
  - teamlage.js fiel auf einen Rollennamen zurueck. Nachgemessen: Die
    Stilvorlage kennt zu dieser Karte gar keine solche Regel -- der
    Rueckfall hat nie etwas bewirkt und nur das Wort ausgeliefert.
  - werdegang.js fuehrte die Saetze ueber den Rollengruppen selbst,
    mit den Rollennamen als Schluessel. Der Kommentar daneben
    behauptete sogar, sie seien "abgeleitet, nicht abgeschrieben".
    Sie kommen jetzt vom Server, und pruef-werdegang misst, dass
    jede Gruppe ihren eigenen behaelt.

pruef-modi-wortleck  8/0  (war 5, davon 1 rot)
pruef-crew-adresse 144/0  (war 135)
pruef-werdegang    98 statt 95 Pruefungen, dieselben 3 alten Fehler
pruef-team-ampel 32/0, pruef-team-stufen 28/0, pruef-hilfe 84/0

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-21 13:00:01 +02:00

304 lines
14 KiB
JavaScript

/* =====================================================================
DIE VERFUEGBARKEITS-AMPEL (Blueprint Kapitel 7.1)
"Persoenlicher Kalender (privat): Inhalt ausschliesslich fuer die
jeweilige Person sichtbar; Owner/rechte Hand sehen nur die
Verfuegbarkeits-Ampel, nicht den Klartext."
Zwei Fragen, und die ERSTE ist die wichtigere:
1. KOMMT WIRKLICH KEIN KLARTEXT DURCH? Die Termine bekommen hier
Titel, Beschreibungen und Orte, die es sonst nirgends gibt
("ZAHNARZT-GEHEIM-4711"). Danach wird die GANZE Antwort nach
ihnen durchsucht -- nicht einzelne Felder, sondern der ganze
Text. Wer ein Feld durchreicht, das er nicht bedacht hat,
faellt damit auf; eine Pruefung, die nur die erwarteten Felder
ansieht, faende genau das nicht.
2. STIMMT DIE AMPEL? Drei Stufen, drei Faelle -- und die Grenze
bei drei Stunden von beiden Seiten.
DAZU DER ABGLEICH MIT DER OFFIZIELLEN REGEL. Die Ampel zaehlt die
Termine ueber eine eigene Mengenvereinigung, weil eine Abfrage je
Person bei zehn Leuten zehn Abfragen waeren. Zwei Wege zu derselben
Frage laufen irgendwann auseinander -- hier werden sie
nebeneinandergelegt. Ohne das waere die Ampel eines Tages leise
falsch, und niemand haette einen Anhaltspunkt: Sie zeigt ja nur eine
Farbe.
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-ampel-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
/* DER PORT MUSS MEINER SEIN (nachgeruestet 19.09.2026).
Ohne diese Zeile startet der eigene Server bei belegtem Port
STILL nicht -- und alles Folgende misst gegen einen fremden
Stand. Das ist der dritte Ausgang: belegt ist etwas anderes
als kaputt. */
const PORT = await eigenerPort(import.meta, "pruef-team-ampel");
process.env.PORT = `${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 }); };
import { notbremse } from "./helfer-notbremse.mjs";
import { eigenerPort } from "./helfer-port.mjs";
await import("./index.js");
notbremse(180_000, "pruef-team-ampel");
await new Promise((r) => setTimeout(r, 700));
const BASIS = `http://127.0.0.1:${PORT}`;
let fehler = 0, geprueft = 0, offen = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const unklar = (t) => { offen++; console.log(" -- " + t); };
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const w = await import("./workspace.js");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const schluessel = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", schluessel, jetzt);
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, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768,
createHmac("sha256", schluessel).update(code).digest("hex"), jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("Rieke", "hand", "CODE-HAND-0001");
const idFrei = anlegen("Frida", "modi", "CODE-MODI-0001"); // nichts eingetragen
const idTeils = anlegen("Tomma", "modi", "CODE-MODI-0002"); // knapp unter der Grenze
const idVoll = anlegen("Vito", "modi", "CODE-MODI-0003"); // darueber
/* HEUTE, wie der Server ihn versteht -- nicht wie ich ihn ausrechne.
Sonst misst die Pruefung um Mitternacht etwas anderes als der
Server, und der Fehlalarm kommt genau dann, wenn niemand hinsieht. */
const heute = w.heuteLokal();
/* DIE GEHEIMWOERTER. Sie stehen in Titel, Beschreibung und Ort -- also
in jedem Feld, das ein Termin traegt. Kaeme eines davon durch, waere
die Grenze aus dem Blueprint gebrochen. */
const GEHEIM = ["ZAHNARZT-GEHEIM-4711", "PRIVATSACHE-BESCHREIBUNG-8899", "ORT-NUR-FUER-MICH-2233"];
function termin(personId, minuten, stunde) {
d.prepare(`INSERT INTO termine
(titel, beschreibung, art, beginn, dauer_min, ort, creator_id, teilnehmer_id,
erledigt, erstellt, erstellt_von)
VALUES (?,?,'termin',?,?,?,?,NULL,0,?,?)`)
.run(GEHEIM[0], GEHEIM[1], `${heute}T${String(stunde).padStart(2, "0")}:00:00`,
minuten, GEHEIM[2], personId, jetzt, personId);
}
/* Tomma: 150 Minuten -- unter der Grenze von 180. */
termin(idTeils, 90, 14);
termin(idTeils, 60, 18);
/* Vito: 240 Minuten -- darueber. */
termin(idVoll, 240, 16);
async function anmelden(rolle, code) {
const a = await fetch(`${BASIS}/workspace/api/anmelden`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle, code }),
});
return (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
}
const holeLage = (keks) => fetch(`${BASIS}/workspace/api/teamlage`, { headers: { Cookie: keks } });
const keksDogi = await anmelden("admin", "CODE-DOGI-0001");
const keksHand = await anmelden("creator", "CODE-HAND-0001");
if (!keksDogi || !keksHand) {
console.error("FEHLER: Anmeldung misslang -- ohne sie misst diese Datei nichts.");
process.exit(1);
}
const antwort = await holeLage(keksDogi);
ok(antwort.status === 200, `die Lage kommt (${antwort.status})`);
const roh = await antwort.text();
const lage = JSON.parse(roh);
const leute = lage.leute || [];
/* =====================================================================
1. KOMMT KLARTEXT DURCH?
===================================================================== */
console.log("");
console.log("--- Was NICHT in der Antwort stehen darf ---");
/* DIE VORAUSSETZUNG ZUERST: Gibt es die Termine ueberhaupt? Ohne sie
waere "kein Geheimwort gefunden" trivial wahr -- der gruene Haken,
der nichts bestaetigt. */
const termineDa = d.prepare("SELECT COUNT(*) AS n FROM termine").get()?.n || 0;
ok(termineDa === 3, `es gibt ${termineDa} Termine mit Klartext zum Finden (3 erwartet)`);
for (const wort of GEHEIM) {
ok(!roh.includes(wort), `"${wort.slice(0, 22)}…" steht nicht in der Antwort`);
}
/* UND DIE GEGENPROBE ZUR SUCHE SELBST: Findet sie ein Wort, das
wirklich dasteht? Sonst waere die Zeile darueber auch dann gruen,
wenn `roh` leer waere. */
ok(roh.includes("Frida"), "die Suche funktioniert (der Name steht sehr wohl darin)");
/* =====================================================================
2. STIMMT DIE AMPEL?
===================================================================== */
console.log("");
console.log("--- Die drei Stufen ---");
const von = (id) => leute.find((m) => m.id === id);
for (const [name, id, erwartet, minuten] of [
["Frida", idFrei, "frei", 0],
["Tomma", idTeils, "teils", 150],
["Vito", idVoll, "voll", 240],
]) {
const m = von(id);
const heuteWert = m?.verfuegbar?.[0];
ok(!!heuteWert, `${name}: die Ampel ist da`);
ok(heuteWert?.ampel === erwartet,
`${name}: heute "${heuteWert?.ampel}" (erwartet "${erwartet}")`);
ok(heuteWert?.minuten === minuten,
`${name}: ${heuteWert?.minuten} belegte Minuten (erwartet ${minuten})`);
}
ok(von(idFrei)?.verfuegbar?.length === 7,
`sieben Tage kommen mit (${von(idFrei)?.verfuegbar?.length})`);
ok(von(idFrei)?.verfuegbar?.[0]?.tag === heute,
`und der erste ist heute (${von(idFrei)?.verfuegbar?.[0]?.tag})`);
/* Die Tage müssen aufeinanderfolgen -- eine Lücke faellt sonst
niemandem auf, die Farben sehen ja gleich aus. */
const tage = von(idFrei)?.verfuegbar?.map((t) => t.tag) || [];
const luecken = tage.filter((t, i) => i > 0
&& Date.parse(`${t}T12:00:00Z`) - Date.parse(`${tage[i - 1]}T12:00:00Z`) !== 86400000);
ok(tage.length === 7 && luecken.length === 0,
`die sieben Tage folgen ohne Luecke aufeinander (${luecken.length} Sprünge)`);
/* =====================================================================
3. DIE GRENZE VON BEIDEN SEITEN
Drei Stunden trennen "teils" von "voll". Nur EINE Seite zu pruefen
hiesse, eine Grenze zu bestaetigen, ohne zu wissen, wo sie liegt.
===================================================================== */
console.log("");
console.log("--- Die Grenze bei drei Stunden ---");
const idGrenze = anlegen("Grete", "modi", "CODE-MODI-0004");
termin(idGrenze, 179, 10);
const g1 = ((await (await holeLage(keksDogi)).json()).leute || []).find((m) => m.id === idGrenze);
ok(g1?.verfuegbar?.[0]?.ampel === "teils", `179 Minuten sind "teils" (${g1?.verfuegbar?.[0]?.ampel})`);
d.prepare("UPDATE termine SET dauer_min = 180 WHERE creator_id = ?").run(idGrenze);
const g2 = ((await (await holeLage(keksDogi)).json()).leute || []).find((m) => m.id === idGrenze);
ok(g2?.verfuegbar?.[0]?.ampel === "voll", `180 Minuten sind "voll" (${g2?.verfuegbar?.[0]?.ampel})`);
/* =====================================================================
4. ZWEI WEGE, EINE ANTWORT
Die Ampel zaehlt ueber eine eigene Mengenvereinigung; die offizielle
Regel heisst termineSichtbar(). Beide muessen dasselbe sagen.
===================================================================== */
console.log("");
console.log("--- Abgleich mit der offiziellen Regel ---");
let gleich = 0, ungleich = 0;
for (const id of [idFrei, idTeils, idVoll, idGrenze]) {
const regel = w.termineSichtbar({ id }, "t");
const nachRegel = d.prepare(
`SELECT COUNT(*) AS n FROM termine t WHERE (${regel.wo}) AND date(t.beginn) = ?`)
.get(...regel.werte, heute)?.n || 0;
const lage2 = ((await (await holeLage(keksDogi)).json()).leute || []).find((m) => m.id === id);
const nachAmpel = lage2?.verfuegbar?.[0]?.termine ?? -1;
if (nachRegel === nachAmpel) gleich++; else ungleich++;
}
ok(gleich === 4 && ungleich === 0,
`beide Wege zaehlen bei allen ${gleich} Personen dasselbe`);
/* =====================================================================
5. WER SIE SEHEN DARF
===================================================================== */
console.log("");
console.log("--- Wer die Ampel sieht ---");
const handLage = await holeLage(keksHand);
ok(handLage.status === 200, `die rechte Hand sieht die Lage (${handLage.status})`);
const handRoh = await handLage.text();
ok(JSON.parse(handRoh).leute?.[0]?.verfuegbar?.length === 7,
"und die Ampel gehoert dazu -- sie koordiniert das Team");
for (const wort of GEHEIM) {
ok(!handRoh.includes(wort), `auch bei ihr steht "${wort.slice(0, 18)}…" nicht drin`);
}
/* =====================================================================
DIE GEGENPROBE
Bis hierhin koennte die Ampel auch einfach immer "frei" sagen -- bei
Frida stimmt das ja. Also bekommt sie jetzt einen Termin, und die
Farbe MUSS sich aendern.
===================================================================== */
console.log("");
console.log("--- Gegenprobe: ein Termin veraendert die Farbe ---");
termin(idFrei, 300, 9);
const nachher = ((await (await holeLage(keksDogi)).json()).leute || []).find((m) => m.id === idFrei);
ok(nachher?.verfuegbar?.[0]?.ampel === "voll",
`aus "frei" wird "voll" (${nachher?.verfuegbar?.[0]?.ampel})`);
d.prepare("DELETE FROM termine WHERE creator_id = ?").run(idFrei);
const zurueck = ((await (await holeLage(keksDogi)).json()).leute || []).find((m) => m.id === idFrei);
ok(zurueck?.verfuegbar?.[0]?.ampel === "frei",
`und zurueckgenommen wieder "frei" (${zurueck?.verfuegbar?.[0]?.ampel})`);
/* =======================================================================
DIE UEBERSCHRIFT MUSS ZU DEM PASSEN, WAS DARUNTER STEHT (11.09.2026)
Diese Datei hat von Anfang an geprueft, dass SIEBEN Tage kommen und
der erste heute ist. Was sie nie angesehen hat: was ueber den sieben
Kaestchen steht. Dort stand "Kann heute" -- im Singular, ueber einer
ganzen Woche.
Wer nur die Ueberschrift liest (und das tut man bei einer Zeile in
Versalien), haelt den ganzen Streifen fuer den heutigen Tag und liest
sechs Kaestchen falsch. Der Satz DARUNTER war immer richtig; zwei
Aussagen ueber demselben Bild, und die auffaelligere war die falsche.
Eine Pruefung ueber die Schnittstelle allein konnte das nicht finden
-- die Zahl stimmte ja. Deshalb hier eine Pruefung auf den TEXT, an
derselben Stelle wie die Zahl, damit beide zusammen gelesen werden.
======================================================================= */
console.log("");
console.log("--- Was steht ueber den sieben Tagen? ---");
{
const quelle = readFileSync(new URL("../workspace/assets/js/teamlage.js", import.meta.url), "utf8");
const schild = /tampel__schild',\s*'([^']*)'/.exec(quelle)?.[1] || "";
ok(!!schild, `die Ueberschrift steht im Skript ("${schild}")`);
ok(!/heute/i.test(schild),
`und sie spricht nicht von EINEM Tag ("${schild}")`);
ok(/sieben|woche|7/i.test(schild),
"sondern von dem Zeitraum, den sie zeigt");
/* GEGENPROBE: Findet die Suche das Wort ueberhaupt? Sonst waere die
Zeile darueber gruen, weil sie nie etwas liest. */
ok(/heute/i.test("Kann heute"), "und die Suche wuerde 'heute' erkennen");
}
/* ---------- Ende ------------------------------------------------------ */
d.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Windows haelt manchmal */ }
console.log("");
console.log(`${fehler === 0 ? "BESTANDEN" : "FEHLGESCHLAGEN"}`
+ ` -- ${geprueft} geprueft, ${fehler} Fehler, ${offen} nicht nachsehbar`);
process.exit(fehler === 0 ? 0 : 1);