/* ===================================================================== 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("hand", "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);