/* ===================================================================== WER IN EINEM KATEGORIE-KANAL STEHT — und wer nicht ===================================================================== VANVANS BEFUND, weitergegeben von Filipe am 22.09.2026: * „Patrick und BananaStift (stifti) aus der Erwähnen-Liste entfernen — sie stehen dort als SCOUT und gehören nicht in diesen Chat." * „Die Modis zum Erwähnen hinzufügen." * „Die Modis sehen den Chat Moderation auch gar nicht." AN DER LIVE-DATENBANK NACHGEMESSEN, bevor etwas gebaut wurde: Raum 3 kanal Chat-Moderation admin, hand, SCOUT, SCOUT, linke Damit waren alle drei Punkte EIN Befund. Die Erwähnen-Liste zeigt genau die Teilnehmer des Raums; standen dort zwei Scouts und kein Modi, dann schlug sie Scouts vor und die Modis sahen den Kanal gar nicht. An der Erwähnung selbst war nichts kaputt. --------------------------------------------------------------------- WAS HIER GEMESSEN WIRD Die Regel hat zwei Richtungen, und eine Prüfung, die nur eine misst, ist die halbe Prüfung: hinein jeder aktive Mensch aus Team Dogi, der im Raum noch NIE eine Zeile hatte hinaus jeder aktive Teilnehmer, dessen Rolle nicht dazugehört Dazu drei Gegenproben, von denen die erste die wichtigste ist: (1) WER BEWUSST HERAUSGENOMMEN WURDE, BLEIBT DRAUSSEN. Ein Abgleich, der eine Entscheidung von Hand beim nächsten Seitenaufruf überschreibt, macht die Mitglieder-Route wertlos. (2) DER TREFF BLEIBT UNBERÜHRT. Er ist technisch ein 'kanal', aber dort gehört die Community dazu. Eine Regel, die über ihr Ziel hinausschießt, wirft die Gäste hinaus. (3) DIE ALTE LAGE WIRD NACHGEBAUT und muss sich heilen. Ohne das wäre nur bewiesen, dass ein FRISCHER Kanal richtig besetzt ist — und genau das war er die ganze Zeit. ===================================================================== */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { notbremse } from "./helfer-notbremse.mjs"; import { eigenerPort } from "./helfer-port.mjs"; const ordner = mkdtempSync(join(tmpdir(), "ws-kanal-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); const PORT = await eigenerPort(import.meta, "pruef-kanal-besetzung"); 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(120_000, "pruef-kanal-besetzung"); await new Promise((r) => setTimeout(r, 800)); 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); }; /* ---------- Leute ------------------------------------------------------ */ const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); 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; } const idChef = anlegen("Filipe", "admin", "CODE-DOGI-0001"); const idHand = anlegen("Rieke", "hand", "CODE-HAND-0001"); const idLinke = anlegen("Lino", "linke", "CODE-LINK-0001"); const idModi = anlegen("Frida", "modi", "CODE-MODI-0001"); const idModi2 = anlegen("Nele", "modi", "CODE-MODI-0002"); const idScout = anlegen("Scout Sam", "scout", "CODE-SCOU-0001"); /* ---------- Anfragen -------------------------------------------------- UEBER node:http UND NICHT UEBER fetch. `fetch` in Node verwirft den Host-Kopf -- und genau an ihm entscheidet der Server, in welchem Haus er antwortet. Ohne ihn kaeme jede Anfrage auf der Agenturseite an, und Team Dogi gibt es dort nicht. Dieselbe Falle hat in diesem Haus schon zweimal Zeit gekostet; deshalb steht sie hier als Kommentar und nicht nur im Gedaechtnis. */ const { request: httpAnfrage } = await import("node:http"); const CREW = "crew.dogfather-universe.com"; function anfrage(pfad, { methode = "GET", keks = "", rumpf = null } = {}) { return new Promise((fertig, schief) => { const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf)); const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: methode, headers: { Host: CREW, Origin: `https://${CREW}`, Referer: `https://${CREW}/workspace/`, ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}), ...(keks ? { Cookie: keks } : {}), }, }, (antwort) => { const st = []; antwort.on("data", (x) => st.push(x)); antwort.on("end", () => { const text = Buffer.concat(st).toString("utf8"); let json = null; try { json = JSON.parse(text); } catch { /* kein JSON */ } fertig({ code: antwort.statusCode, json, keks: [].concat(antwort.headers["set-cookie"] || []) .map((c) => c.split(";")[0]).join("; "), }); }); }); a.on("error", schief); if (daten) a.write(daten); a.end(); }); } /* Die Rolle gehoert in den Rumpf -- die Anmeldewand schickt sie mit, und der Server prueft Code UND Rolle gegeneinander. */ const anmelden = async (rolle, code) => (await anfrage("/workspace/api/anmelden", { methode: "POST", rumpf: { rolle, code } })).keks; const ruf = (keks, pfad, { methode = "GET", rumpf = null } = {}) => anfrage(pfad, { methode, keks, rumpf }); const keksChef = await anmelden("admin", "CODE-DOGI-0001"); const keksModi = await anmelden("modi", "CODE-MODI-0001"); console.log("=== Wer in einem Kategorie-Kanal steht ==="); console.log(""); /* ===================================================================== 1. EIN KANAL LAESST NIEMANDEN VON AUSSEN HEREIN ===================================================================== */ console.log("--- Anlegen ---"); const mitScout = await ruf(keksChef, "/workspace/api/chat/kanal", { methode: "POST", rumpf: { kategorie: "clipping", mit: [idScout] }, }); ok(mitScout.code === 403, `ein Scout kommt beim Anlegen nicht hinein (${mitScout.code})`); const angelegt = await ruf(keksChef, "/workspace/api/chat/kanal", { methode: "POST", rumpf: { kategorie: "chat", mit: [] }, }); ok(angelegt.code === 201, `der Kanal entsteht (${angelegt.code})`); const raumId = angelegt.json?.raum_id; /* ===================================================================== 2. DIE ALTE LAGE NACHBAUEN — genau der Zustand aus der Live-Datenbank ===================================================================== */ console.log(""); console.log("--- Die gemeldete Lage nachgebaut: zwei Scouts drin, kein Modi ---"); d.prepare("INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)") .run(raumId, idScout, jetzt); /* Und die Modis wieder hinaus, so als hätte sie nie jemand eingetragen — DELETE und nicht raus_am, denn „nie eine Zeile" ist der Zustand, den es nachzubilden gilt. */ d.prepare("DELETE FROM chat_teilnehmer WHERE raum_id = ? AND person_id IN (?,?)") .run(raumId, idModi, idModi2); /* Eine Nachricht, damit es etwas zu bewahren gibt. */ d.prepare(`INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)`).run(raumId, idScout, "Alte Nachricht vom Scout", jetzt); const vorher = d.prepare(`SELECT p.rolle FROM chat_teilnehmer t JOIN personen p ON p.id = t.person_id WHERE t.raum_id = ? AND t.raus_am IS NULL ORDER BY p.rolle`).all(raumId).map((z) => z.rolle); ok(vorher.includes("scout") && !vorher.includes("modi"), `nachgebaut: ${vorher.join(", ")} — ein Scout drin, kein Modi`); /* ===================================================================== 3. DER ABGLEICH HEILT ES ===================================================================== */ console.log(""); console.log("--- Ein Blick in die Gespraechsliste gleicht ab ---"); const liste = await ruf(keksChef, "/workspace/api/chat/raeume"); ok(liste.code === 200, `die Liste kommt (${liste.code})`); const raum = (liste.json?.raeume || []).find((r) => r.id === raumId); const namen = (raum?.teilnehmer || []).map((t) => t.name); ok(!!raum, "der Kanal steht in der Liste"); ok(namen.length > 0 && namen.includes("Frida") && namen.includes("Nele"), `die Modis stehen jetzt drin (${namen.join(", ")})`); ok(namen.length > 0 && !namen.includes("Scout Sam"), `und der Scout nicht mehr (${namen.join(", ")})`); /* DIE ERWAEHNEN-LISTE IST GENAU DIESE LISTE. Der Browser filtert `offen.teilnehmer` — eine zweite Quelle gibt es nicht. Wer das hier misst, misst damit auch, was beim Tippen von „@" vorgeschlagen wird. */ ok(namen.length === 5, `fünf Leute im Kanal: DogFather, beide Hände, beide Modis (${namen.length})`); /* DER VERLAUF BLEIBT. `raus_am` und kein DELETE — was der Scout geschrieben hat, verschwindet nicht rückwirkend. */ const nachrichtDa = d.prepare( "SELECT COUNT(*) AS n FROM chat_nachrichten WHERE raum_id = ?").get(raumId).n; const zeileDa = d.prepare( "SELECT raus_am FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?") .get(raumId, idScout); ok(nachrichtDa === 1 && !!zeileDa?.raus_am, `seine Nachricht bleibt lesbar, seine Zeile trägt raus_am (${nachrichtDa} Nachricht)`); /* ===================================================================== 4. UND DER MODI SIEHT DEN KANAL ===================================================================== */ console.log(""); console.log("--- Aus der Sicht eines Modis ---"); const modiListe = await ruf(keksModi, "/workspace/api/chat/raeume"); const modiRaum = (modiListe.json?.raeume || []).find((r) => r.id === raumId); ok(!!modiRaum, "der Modi hat den Kanal in seiner Liste — genau der gemeldete Punkt"); ok((modiRaum?.teilnehmer || []).some((t) => t.name === "Rieke"), "und er kann die rechte Hand darin erwähnen"); /* ===================================================================== 5. GEGENPROBEN ===================================================================== */ console.log(""); console.log("--- Gegenproben ---"); /* (1) Wer bewusst herausgenommen wurde, bleibt draussen. */ const raus = await ruf(keksChef, `/workspace/api/chat/kanal/${raumId}/mitglieder`, { methode: "PUT", rumpf: { mit: [idLinke, idModi2] }, }); ok(raus.code === 200 && raus.json?.raus >= 1, `Frida wird von Hand herausgenommen (${raus.code}, -${raus.json?.raus})`); await ruf(keksChef, "/workspace/api/chat/raeume"); const nachAbgleich = d.prepare( "SELECT raus_am FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?") .get(raumId, idModi); ok(!!nachAbgleich?.raus_am, "und der nächste Abgleich holt sie NICHT zurück — sonst wäre die" + " Mitglieder-Route wertlos"); /* (2) Ein Scout kommt auch über die Mitglieder-Route nicht hinein. */ const scoutRein = await ruf(keksChef, `/workspace/api/chat/kanal/${raumId}/mitglieder`, { methode: "PUT", rumpf: { mit: [idModi2, idScout] }, }); ok(scoutRein.code === 403, `und über die Mitglieder-Route auch nicht (${scoutRein.code})`); /* (3) Der Treff bleibt unberührt — dort gehört die Community dazu. */ const treff = d.prepare( "SELECT id FROM chat_raeume WHERE art = 'kanal' AND kategorie = 'treff'").get(); if (!treff) { unklar("der Treff-Kanal existiert in dieser Probe-Datenbank nicht —" + " ohne ihn lässt sich nicht messen, ob er verschont bleibt"); } else { const gastId = anlegen("Gast Greta", "gast", "CODE-GAST-0001"); d.prepare("INSERT OR IGNORE INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis)" + " VALUES (?,?,?,0)").run(treff.id, gastId, jetzt); await ruf(keksChef, "/workspace/api/chat/raeume"); const gastNoch = d.prepare( "SELECT raus_am FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?") .get(treff.id, gastId); ok(gastNoch && !gastNoch.raus_am, "der Gast bleibt im Treff — die Regel schießt nicht über ihr Ziel hinaus"); } /* (4) Und die Messung selbst kann „nicht in Ordnung" sagen: Ein Scout, der NEU in einen Kanal geschrieben wird, muss beim nächsten Abgleich wieder verschwinden. Ohne diesen Durchgang wäre nur bewiesen, dass einmal aufgeräumt wurde. */ d.prepare("UPDATE chat_teilnehmer SET raus_am = NULL WHERE raum_id = ? AND person_id = ?") .run(raumId, idScout); const wiederDrin = d.prepare( "SELECT raus_am FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?") .get(raumId, idScout); await ruf(keksChef, "/workspace/api/chat/raeume"); const wiederRaus = d.prepare( "SELECT raus_am FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?") .get(raumId, idScout); ok(!wiederDrin?.raus_am && !!wiederRaus?.raus_am, "ein wieder hereingeschmuggelter Scout fliegt beim nächsten Abgleich erneut hinaus" + " — die Regel wirkt dauerhaft, nicht einmalig"); /* ---------- 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);