/* ===================================================================== DIE REACTION (28.09.2026) Geprueft wird der ganze Weg aus Filipes Kurznotiz: Kachel sichtbar -> geschlossen -> Vorbereitung -> Wartebereich/Chat -> Countdown -> LIVE -> Reaction + Gaeste + Chat + PayPal -> Ende --------------------------------------------------------------------- WARUM DIESE PRUEFUNG UEBER DIE HAUSADRESSE SPRICHT Die Community meldet sich ueber den STILLEN ZUGANG an -- ein Modi oder ein Zuschauer tippt auf irgendeine Kachel und gibt seinen Code ein, der Code allein entscheidet. Dieser Weg ist auf einer Pruefadresse (127.0.0.1) absichtlich abgeschaltet: Er wuerde dort eine messbare Zeitspur hinterlassen, und genau die soll er nicht. Folge: Ohne Host-Kopfzeile koennte sich hier kein einziger Zuschauer anmelden -- und eine Pruefung, die ihren Hauptdarsteller nicht hereinbekommt, prueft nichts. `fetch` kann die Kopfzeile nicht setzen, deshalb node:http. Aufrufen mit: node server/pruef-reaktion.mjs ===================================================================== */ import { mkdtempSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { request } from "node:http"; const HIER = dirname(fileURLToPath(import.meta.url)); const ordner = mkdtempSync(join(tmpdir(), "ws-reakt-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); import { notbremse } from "./helfer-notbremse.mjs"; const { eigenerPort } = await import("./helfer-port.mjs"); const PORT = await eigenerPort(import.meta, "die Reaction-Pruefung"); 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 }); }; await import("./index.js"); notbremse(260_000, "pruef-reaktion"); await new Promise((r) => setTimeout(r, 900)); let fehler = 0, geprueft = 0; const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const melde = (t) => console.log(t); process.on("exit", () => { try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } }); const CREW = "crew.dogfather-universe.com"; const AGENTUR = "workspace.dogfather-universe.com"; /* ---------- Zugaenge anlegen -------------------------------------------- */ const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const nun = new Date().toISOString(); const kennSchluessel = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", kennSchluessel, nun); /* 200 Tage alt: Der Treff hat Stufen, und wer neu ist, darf weniger. Fuer die Reaction spielt das keine Rolle -- aber ein Zugang von heute waere ein Sonderfall, den niemand im Blick hat. */ const ALT = new Date(Date.now() - 200 * 86400000).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," + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, createHmac("sha256", kennSchluessel).update(code).digest("hex"), ALT); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } const ROLLEN = [["admin", "Filipe"], ["hand", "VanVan"], ["linke", "Funny"], ["modi", "Miss"], ["gast", "Nachtfalter"], ["gast2", "Lenny"], ["creator", "Luna"]]; const CODES = {}; const IDS = {}; for (const [i, [rolle, name]] of ROLLEN.entries()) { CODES[rolle] = `CODE-R${String(i).padStart(2, "0")}-000001`; IDS[rolle] = anlegen(name, rolle === "gast2" ? "gast" : rolle, CODES[rolle]); } d.close(); /* ---------- Sprechen mit Hausadresse ------------------------------------ */ function roh(host, pfad, { method = "GET", keks = "", rumpf = null, origin } = {}) { return new Promise((fertig, schief) => { const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf)); const a = request({ host: "127.0.0.1", port: PORT, path: pfad, method, headers: { Host: host, Origin: origin === undefined ? `https://${host}` : origin, ...(keks ? { Cookie: keks } : {}), ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}), }, }, (antwort) => { const teile = []; antwort.on("data", (s) => teile.push(s)); antwort.on("end", () => { const text = Buffer.concat(teile).toString("utf8"); let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ } fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers }); }); }); a.on("error", schief); if (daten) a.write(daten); a.end(); }); } async function anmelden(rolle, host) { const a = await roh(host, "/workspace/api/anmelden", { method: "POST", rumpf: { rolle: rolle === "gast2" ? "gast" : rolle, code: CODES[rolle], alter_ok: true }, }); const keks = [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); return a.code === 200 && keks ? keks : null; } const k = {}; for (const [rolle] of ROLLEN) k[rolle] = await anmelden(rolle, CREW); /* ======================================================================= 1. ALLE KOMMEN HEREIN — auch die Community ======================================================================= */ melde("=== 1. Wer die Reaction sieht ==="); { const fehlend = ROLLEN.map(([r]) => r).filter((r) => r !== "creator" && !k[r]); ok(fehlend.length === 0, `alle Rollen des Hauses sind angemeldet (${fehlend.length ? "fehlt: " + fehlend.join(", ") : "5 von 5"})`); for (const rolle of ["admin", "hand", "linke", "modi", "gast"]) { const a = await roh(CREW, "/workspace/api/reaktion", { keks: k[rolle] }); ok(a.code === 200 && a.j?.stand === "zu", `${rolle}: sieht die Lage und sie ist „zu" (${a.code}/${a.j?.stand})`); } const s = await roh(CREW, "/workspace/reaktion.html", { keks: k.gast }); ok(s.code === 200, `die Seite steht der Community offen (${s.code})`); } /* ======================================================================= 2. DAS ANDERE HAUS SIEHT NICHTS ======================================================================= */ melde(""); melde("=== 2. Die Agentur kennt sie nicht ==="); { const kA = await anmelden("admin", AGENTUR); ok(!!kA, "DogFather meldet sich auch auf der Agenturadresse an"); const a = await roh(AGENTUR, "/workspace/api/reaktion", { keks: kA }); ok(a.code === 404, `und bekommt dort 404 (${a.code})`); const s = await roh(AGENTUR, "/workspace/reaktion.html", { keks: kA }); ok(s.code === 404, `auch die Seite gibt es dort nicht (${s.code})`); /* Das andere Ende derselben Regel: Auf einer Pruefadresse hat niemand ein Haus, und die Schranke darf das nicht mit dem fremden Haus verwechseln. Genau dieser Fehler ist am 27.09. beim Notizblock passiert. */ const kP = await anmelden("admin", "127.0.0.1"); const p = await roh("127.0.0.1", "/workspace/api/reaktion", { keks: kP }); ok(p.code === 200, `auf der Pruefadresse ist sie offen (${p.code}) -- ohne Haus wird nicht gesperrt`); } /* ======================================================================= 3. NUR DER HOST STELLT EIN ======================================================================= */ melde(""); melde("=== 3. Wer die Sendung fuehrt ==="); { for (const rolle of ["hand", "linke", "modi", "gast"]) { const a = await roh(CREW, "/workspace/api/reaktion", { method: "PUT", keks: k[rolle], rumpf: { titel: "Nicht du" }, }); ok(a.code === 403, `${rolle}: darf nicht einstellen (${a.code})`); } const a = await roh(CREW, "/workspace/api/reaktion", { method: "PUT", keks: k.admin, rumpf: { titel: "Wir schauen den Trailer", video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" }, }); ok(a.code === 200 && a.j?.video === "dQw4w9WgXcQ", `DogFather schon -- und aus der Adresse wird die Kennung (${a.j?.video})`); } /* ---- Die Kennung kommt aus jeder Form, die man wirklich einfuegt ---- */ { const { videoKennung } = await import("./workspace-reaktion.js"); const faelle = [ ["https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s", "dQw4w9WgXcQ"], ["https://youtu.be/dQw4w9WgXcQ", "dQw4w9WgXcQ"], ["https://www.youtube.com/embed/dQw4w9WgXcQ", "dQw4w9WgXcQ"], ["https://www.youtube.com/shorts/dQw4w9WgXcQ", "dQw4w9WgXcQ"], ["dQw4w9WgXcQ", "dQw4w9WgXcQ"], ]; const sitzt = faelle.filter(([roh2, soll]) => videoKennung(roh2) === soll).length; ok(sitzt === faelle.length, `alle ${faelle.length} Formen der Adresse ergeben die Kennung`); /* Gegenprobe: Was keine Kennung ist, darf auch keine werden -- sonst stuende vor Publikum ein schwarzes Rechteck. */ const mist = ["", "https://vimeo.com/12345", "kein-video", "https://www.youtube.com/watch?v=zu_kurz"]; ok(mist.every((m) => videoKennung(m) === ""), "und was keine ist, wird auch keine (Gegenprobe)"); const b = await roh(CREW, "/workspace/api/reaktion", { method: "PUT", keks: k.admin, rumpf: { video: "https://vimeo.com/12345" }, }); ok(b.code === 400 && b.j?.fehler === "video_unklar", `eine fremde Adresse wird abgelehnt (${b.code}/${b.j?.fehler})`); } /* ======================================================================= 4. DER ABLAUF ======================================================================= */ melde(""); melde("=== 4. zu -> vorbereitung -> live -> zu ==="); { const a = await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "vorbereitung" }, }); ok(a.code === 200 && a.j?.stand === "vorbereitung", `Vorbereitung geht (${a.j?.stand})`); const b = await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "erfunden" }, }); ok(b.code === 400, `ein erfundener Stand wird abgelehnt (${b.code})`); /* Im Wartebereich darf schon geschrieben werden -- das ist der halbe Sinn der Vorbereitung. */ const c = await roh(CREW, "/workspace/api/reaktion/chat", { method: "POST", keks: k.gast, rumpf: { text: "Bin schon da!" }, }); ok(c.code === 201, `im Wartebereich ist der Chat offen (${c.code})`); const e = await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "live" }, }); ok(e.code === 200 && e.j?.stand === "live" && e.j?.gestartet_am, `auf Sendung -- mit Startzeit (${e.j?.stand})`); } /* ---- Ohne Video kein Live ---- */ { await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "zu" } }); await roh(CREW, "/workspace/api/reaktion", { method: "PUT", keks: k.admin, rumpf: { titel: "Ohne", video: "" } }); const a = await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "live" }, }); ok(a.code === 400 && a.j?.fehler === "kein_video", `ohne Video laesst sich nicht senden (${a.code}/${a.j?.fehler})`); /* und danach wieder aufbauen */ await roh(CREW, "/workspace/api/reaktion", { method: "PUT", keks: k.admin, rumpf: { titel: "Wir schauen den Trailer", video: "dQw4w9WgXcQ" }, }); await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "live" } }); } /* ======================================================================= 5. WER DABEI IST ======================================================================= */ melde(""); melde("=== 5. Dabei sein ==="); { for (const rolle of ["gast", "gast2", "modi"]) { await roh(CREW, "/workspace/api/reaktion/dabei", { method: "POST", keks: k[rolle] }); } const a = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin }); ok(a.j?.dabei === 3, `drei sind dabei (${a.j?.dabei})`); ok(Array.isArray(a.j?.zuschauer) && a.j.zuschauer.length === 3, "DogFather sieht, WER dabei ist"); const z = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast }); ok(z.j?.zuschauer === null, "ein Zuschauer bekommt die Namensliste NICHT"); ok(z.j?.dabei === 3, `die Zahl sieht er aber (${z.j?.dabei})`); /* Jeder Dabeisitzende hat einen Kameraplatz bekommen -- es sind erst drei, und zwoelf waeren erlaubt. */ ok(a.j.zuschauer.every((x) => x.sicht === true), "alle drei haben einen Kameraplatz (bei 12 Plaetzen)"); /* Abmelden: der Weg, den `sendBeacon` beim Schliessen benutzt -- POST, nicht DELETE. Beides muss gehen. */ const w1 = await roh(CREW, "/workspace/api/reaktion/dabei-weg", { method: "POST", keks: k.modi }); ok(w1.code === 200, `abmelden per POST geht (fuer sendBeacon) (${w1.code})`); const n1 = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin }); ok(n1.j?.dabei === 2, `danach sind es zwei (${n1.j?.dabei})`); await roh(CREW, "/workspace/api/reaktion/dabei", { method: "POST", keks: k.modi }); } /* ======================================================================= 6. GAESTE ======================================================================= */ melde(""); melde("=== 6. Ein bis zwei Gaeste ==="); { const { GAESTE_MAX } = await import("./reaktion-tabellen.js"); ok(GAESTE_MAX === 2, `zwei Plaetze, wie in der Notiz (${GAESTE_MAX})`); const a = await roh(CREW, "/workspace/api/reaktion/gast", { method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast }, }); ok(a.code === 201 && a.j?.gaeste?.length === 1, `Gast 1 dazu (${a.code})`); ok(a.j.gaeste[0].platz === 1, `er bekommt Platz 1 (${a.j?.gaeste?.[0]?.platz})`); const b = await roh(CREW, "/workspace/api/reaktion/gast", { method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast2 }, }); ok(b.code === 201 && b.j?.gaeste?.length === 2, `Gast 2 dazu (${b.code})`); const c = await roh(CREW, "/workspace/api/reaktion/gast", { method: "POST", keks: k.admin, rumpf: { person_id: IDS.modi }, }); ok(c.code === 409 && c.j?.fehler === "gaeste_voll", `ein dritter passt nicht (${c.code}/${c.j?.fehler})`); /* EIN GAST BEKOMMT DIE NAMENSLISTE -- und das ist kein Zufall: Ohne sie wuesste er nicht, wen er anrufen soll, und sein Bild kaeme bei keinem Zuschauer an. */ const g = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast }); ok(Array.isArray(g.j?.zuschauer), "wer auf der Buehne steht, bekommt die Liste (sonst sieht ihn niemand)"); ok(g.j?.ich?.gast === true, "und er weiss, dass er Gast ist"); /* Stummschalten gilt fuer alle -- das steht am Server, nicht im Browser des Hosts. */ const s = await roh(CREW, `/workspace/api/reaktion/gast/${IDS.gast}/stumm`, { method: "PUT", keks: k.admin, rumpf: { stumm: true }, }); ok(s.code === 200 && s.j?.gaeste?.find((x) => x.id === IDS.gast)?.stumm === true, "stummschalten wirkt"); const s2 = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast2 }); ok(s2.j?.gaeste?.find((x) => x.id === IDS.gast)?.stumm === true, "und alle anderen sehen es auch (nicht nur der Host)"); /* Ein Gast kann sich nicht selbst holen oder entfernen. */ const f = await roh(CREW, "/workspace/api/reaktion/gast", { method: "POST", keks: k.gast, rumpf: { person_id: IDS.modi }, }); ok(f.code === 403, `ein Gast holt niemanden dazu (${f.code})`); const w = await roh(CREW, `/workspace/api/reaktion/gast/${IDS.gast2}`, { method: "DELETE", keks: k.admin, }); ok(w.code === 200 && w.j?.gaeste?.length === 1, `entfernen geht (${w.code})`); /* Der frei gewordene Platz wird WIEDERVERWENDET. Mit „Anzahl + 1" bliebe Platz 1 fuer immer leer, sobald einmal jemand gegangen ist -- und das faellt erst im dritten Stream auf. */ const neu = await roh(CREW, "/workspace/api/reaktion/gast", { method: "POST", keks: k.admin, rumpf: { person_id: IDS.modi }, }); const plaetze = (neu.j?.gaeste || []).map((x) => x.platz).sort(); ok(plaetze.join(",") === "1,2", `der frei gewordene Platz wird wiederverwendet (${plaetze.join(",")})`); } /* ======================================================================= 7. DER LIVE-CHAT UND SEINE MODERATION ======================================================================= */ melde(""); melde("=== 7. Chat und Moderation ==="); let fremderBeitrag = 0; { const a = await roh(CREW, "/workspace/api/reaktion/chat", { method: "POST", keks: k.gast2, rumpf: { text: "Das ist gut!" }, }); ok(a.code === 201 && a.j?.id > 0, `schreiben geht (${a.code})`); fremderBeitrag = a.j.id; const leer = await roh(CREW, "/workspace/api/reaktion/chat", { method: "POST", keks: k.gast2, rumpf: { text: " " }, }); ok(leer.code === 400, `ein leerer Beitrag wird abgelehnt (${leer.code})`); const liste = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.gast }); ok(liste.code === 200 && liste.j.beitraege.some((b) => b.id === fremderBeitrag), "und steht in der Liste"); /* Ein Zuschauer nimmt einen FREMDEN Beitrag nicht weg. */ const nein = await roh(CREW, `/workspace/api/reaktion/chat/${fremderBeitrag}`, { method: "DELETE", keks: k.gast, }); ok(nein.code === 403, `ein Zuschauer nimmt fremde Beitraege nicht weg (${nein.code})`); /* Sein eigener geht. */ const eigen = await roh(CREW, "/workspace/api/reaktion/chat", { method: "POST", keks: k.gast, rumpf: { text: "ups" }, }); const weg = await roh(CREW, `/workspace/api/reaktion/chat/${eigen.j.id}`, { method: "DELETE", keks: k.gast, }); ok(weg.code === 200, `den eigenen schon (${weg.code})`); /* Ein Modi moderiert. */ const mod = await roh(CREW, `/workspace/api/reaktion/chat/${fremderBeitrag}`, { method: "DELETE", keks: k.modi, }); ok(mod.code === 200, `ein Modi moderiert (${mod.code})`); const nach = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.gast }); ok(!nach.j.beitraege.some((b) => b.id === fremderBeitrag), "und der Beitrag ist aus der Liste"); /* WEGGENOMMEN UND NICHT GELOESCHT -- wer moderiert, soll spaeter sagen koennen, was er weggenommen hat. */ const { DatabaseSync: DB2 } = await import("node:sqlite"); const d2 = new DB2(process.env.WORKSPACE_DB, { readOnly: true }); const zeile = d2.prepare("SELECT weg_am, weg_von, text FROM reaktion_chat WHERE id = ?") .get(fremderBeitrag); d2.close(); ok(!!zeile && !!zeile.weg_am && zeile.weg_von === IDS.modi && zeile.text === "Das ist gut!", "er steht noch da -- mit Zeitpunkt und Namen dessen, der ihn genommen hat"); } /* ---- Die Schreibbremse ---- */ { const { SCHREIB_BREMSE } = await import("./reaktion-tabellen.js"); let gebremst = 0; for (let i = 0; i < SCHREIB_BREMSE + 6; i++) { const a = await roh(CREW, "/workspace/api/reaktion/chat", { method: "POST", keks: k.linke, rumpf: { text: "spam " + i }, }); if (a.code === 429) gebremst++; } ok(gebremst > 0, `die Schreibbremse greift (${gebremst} von ${SCHREIB_BREMSE + 6} abgewiesen)`); } /* ======================================================================= 8. DIE VERBINDUNGSDATEN GEHEN NUR AN LEUTE, DIE DABEI SIND ======================================================================= */ melde(""); melde("=== 8. Kameras: der Hinweg ==="); { const a = await roh(CREW, "/workspace/api/reaktion/signal", { method: "POST", keks: k.admin, rumpf: { an: IDS.gast, daten: { typ: "angebot" } }, }); ok(a.code === 200, `an jemanden, der dabei ist, geht es (${a.code})`); /* Die Creatorin ist nicht dabei -- und im anderen Haus. An sie darf nichts gehen, sonst waere die Reaction ein Weg, jemanden anzuklingeln. */ const b = await roh(CREW, "/workspace/api/reaktion/signal", { method: "POST", keks: k.admin, rumpf: { an: IDS.creator, daten: { typ: "angebot" } }, }); ok(b.code === 409 && b.j?.fehler === "nicht_dabei", `an jemanden, der nicht dabei ist, nicht (${b.code}/${b.j?.fehler})`); const c = await roh(CREW, "/workspace/api/reaktion/signal", { method: "POST", keks: k.admin, rumpf: { an: IDS.gast }, }); ok(c.code === 400, `ohne Inhalt wird abgelehnt (${c.code})`); } /* ======================================================================= 9. DAS ENDE RAEUMT AUF ======================================================================= */ melde(""); melde("=== 9. Beenden ==="); { const vorher = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.admin }); ok(vorher.j.beitraege.length > 0, `vor dem Ende steht etwas im Chat (${vorher.j.beitraege.length})`); const a = await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "zu" }, }); ok(a.code === 200 && a.j?.stand === "zu", `beenden geht (${a.j?.stand})`); ok(a.j.gaeste.length === 0, "die Gaeste sind entlassen"); ok(a.j.dabei === 0, "und niemand sitzt mehr im Saal"); const nach = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.admin }); ok(nach.j.beitraege.length === 0, "der Live-Chat dieser Sendung ist abgeraeumt -- die naechste faengt nicht mit den Rufen der letzten an"); /* Was gelaufen ist, steht im Verlauf. */ const { DatabaseSync: DB3 } = await import("node:sqlite"); const d3 = new DB3(process.env.WORKSPACE_DB, { readOnly: true }); const v = d3.prepare("SELECT * FROM reaktion_verlauf ORDER BY id DESC LIMIT 1").get(); d3.close(); ok(!!v && v.titel === "Wir schauen den Trailer" && v.beitraege > 0, `die Sendung steht im Verlauf (${v?.titel}, ${v?.beitraege} Beitraege)`); /* Bei „zu" ist der Chat zu -- sonst redeten die Leute in einen leeren Saal. */ const c = await roh(CREW, "/workspace/api/reaktion/chat", { method: "POST", keks: k.gast, rumpf: { text: "Hallo?" }, }); ok(c.code === 409 && c.j?.fehler === "geschlossen", `und schreiben geht nicht mehr (${c.code}/${c.j?.fehler})`); } /* ======================================================================= 10. DIE KACHEL, DIE RECHTE UND DIE INHALTSRICHTLINIE ======================================================================= */ melde(""); melde("=== 10. Was um die Seite herum stimmen muss ==="); { const { SEITEN } = await import("./rechte.js"); const erlaubt = SEITEN["/workspace/reaktion.html"] || []; ok(erlaubt.includes("gast"), `die Community steht in der Rechtetafel (${erlaubt.join(", ")})`); /* Die Kachel. Gesucht wird sie am ZIEL und nicht am Namen -- Namen haben sich in diesem Haus schon zweimal geaendert. */ const w = await import("./workspace.js"); const alle = w.bereicheFuer({ id: IDS.gast, rolle: "gast", name: "Nachtfalter" }, CREW); const kachel = (alle || []).find((b) => b.ziel === "reaktion.html"); ok(!!kachel, "die Community hat die Reaction-Kachel"); ok(kachel?.live === true, "sie traegt das Merkmal `live` statt einer Ton-Nummer (der Farbraum ist bei 46 voll)"); ok(!kachel?.ton, "und ausdruecklich KEINE Ton-Nummer"); /* DAS MERKMAL HEISST NICHT `data-live`. Das ist seit dem 21.09. vergeben -- die Draussen-Kachel bekommt es, sobald Filipe auf Twitch sendet. Haetten beide denselben Namen, waere der Draussen-Kachel waehrend jedes Streams die Farbe abhanden gekommen. */ const startJs = readFileSync(join(HIER, "..", "workspace", "assets", "js", "start.js"), "utf8"); ok(/dataset\.sendung = 'ja'/.test(startJs), "die Wand setzt `data-sendung` (und nicht `data-live`, das ist vergeben)"); const startCss = readFileSync(join(HIER, "..", "workspace", "assets", "css", "start.css"), "utf8"); ok(!/\.kachel\[data-live="ja"\]\s*\{\s*--ton/.test(startCss), "und kein Stil greift der Draussen-Kachel ihre Farbe ab"); /* DIE INHALTSRICHTLINIE. Ohne YouTube darin startet die Sendung, alle sind da, und die Leinwand bleibt schwarz -- ohne Fehlermeldung, nur ein Eintrag in der Browser-Konsole. Genau so ist am 27.08.2026 der Musik-Knopf ausgefallen. */ const seite = await roh(CREW, "/workspace/reaktion.html", { keks: k.gast }); const regel = String(seite.kopf["content-security-policy"] || ""); ok(/frame-src[^;]*youtube-nocookie\.com/.test(regel), "der YouTube-Rahmen ist erlaubt"); ok(/script-src[^;]*www\.youtube\.com/.test(regel), "und die Einbett-API darf geladen werden"); ok(/img-src[^;]*ytimg\.com/.test(regel), "und die Vorschaubilder auch"); } /* ======================================================================= 11. KEIN WEG HEISST ZWEIMAL GLEICH ======================================================================= */ melde(""); melde("=== 11. Keine doppelten Wege ==="); { /* Beim Notizblock hat genau das eine Stunde gekostet: `DELETE /workspace/api/notizen/:id` gab es schon woanders, und express nimmt schweigend die erste passende Route. */ const { default: app } = await import("./index.js"); const wege = []; const sammeln = (stapel, vorn = "") => { for (const s of stapel) { if (s.route) { for (const m of Object.keys(s.route.methods)) { wege.push(`${m.toUpperCase()} ${vorn}${s.route.path}`); } } else if (s.handle?.stack) { sammeln(s.handle.stack, vorn); } } }; sammeln(app._router.stack); ok(wege.length > 300, `${wege.length} Wege im Baum gefunden`); const doppelt = wege.filter((x, i) => wege.indexOf(x) !== i); ok(doppelt.length === 0, doppelt.length ? `DOPPELT: ${[...new Set(doppelt)].join(", ")}` : "kein Weg kommt zweimal vor"); const reaktionswege = wege.filter((x) => x.includes("/api/reaktion")).length; ok(reaktionswege >= 11, `die Reaction bringt ${reaktionswege} eigene Wege mit`); } melde(""); melde(`${geprueft} Pruefungen, ${fehler} Fehler`); melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG"); process.exit(fehler === 0 ? 0 : 1);