Files
dogfather-universe/server/pruef-chat-kanaele.mjs
T
DogFatherGitandClaude Opus 5 95da1345fc B9: In einen Kategorie-Kanal gehoert Team Dogi -- und alle davon
VanVans Befund, von Filipe weitergegeben:
  * "Patrick und BananaStift (stifti) aus der Erwaehnen-Liste
     entfernen -- sie stehen dort als SCOUT."
  * "Die Modis zum Erwaehnen hinzufuegen."
  * "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
    Raum 7  kanal  Der Treff         admin, hand, 4x modi, linke, 3x gast
    Raum 8  gruppe Dogi und Modis    admin, hand, 4x modi, linke
    Raum 9  gruppe Abmeldungen       admin, hand, 4x modi, linke

Damit waren alle drei Punkte EIN Befund. Die Erwaehnen-Liste im
Browser zeigt genau `offen.teilnehmer`, also die Teilnehmer des Raums
(teilnehmerVon) -- eine zweite Quelle gibt es nicht. Standen dort zwei
Scouts und kein Modi, dann schlug sie Scouts vor, und die Modis sahen
den Kanal gar nicht. An der Erwaehnung selbst war nichts kaputt.

DIE REGEL

Ein Kategorie-Kanal gehoert Team Dogi: admin + TEAM_DOGI_ROLLEN
(hand, linke, modi), abgeleitet aus der einen Liste des Hauses. Der
Abgleich beim Blick in die Gespraechsliste heilt jetzt in BEIDE
Richtungen:

  hinein  jeder aktive Mensch aus Team Dogi, der im Raum NOCH NIE eine
          Zeile hatte
  hinaus  jeder aktive Teilnehmer, dessen Rolle nicht dazugehoert
          (raus_am, kein DELETE -- der Verlauf bleibt lesbar)

"NOCH NIE EINE ZEILE" ist der wichtige Teil: Wer ueber die
Mitglieder-Route bewusst herausgenommen wurde, hat eine Zeile mit
raus_am und wird NICHT zurueckgeholt. Ein Abgleich, der eine
Entscheidung von Hand beim naechsten Seitenaufruf ueberschreibt,
macht die Route wertlos -- dieselbe Ueberlegung wie bei
treffAngleichen(). Der Treff bleibt ausgenommen: Dort gehoert die
Community dazu.

Dazu dieselbe Schranke an beiden Schreibwegen (Kanal anlegen und
umbesetzen). `darfSchreibenMit` beantwortet eine ANDERE Frage -- "darf
ich diesen Menschen ueberhaupt anschreiben" -- und sagt bei einem
Scout zu Recht ja. Genau diese Luecke hat die zwei Scouts in den
Moderations-Kanal gebracht.

WARUM NICHT "NUR DIE ZUSTAENDIGEN"

Weil es die Zuordnung nicht gibt: `kategorienFuer` gibt JEDEM aus Team
Dogi ALLE Kategorien, eine Tabelle Person -> Kategorie existiert
nirgends. Eine Regel "nur die Zustaendigen" waere eine Liste, die
niemand pflegt. Wer einen engeren Kanal will, nimmt jemanden heraus --
und das haelt.

PRUEFUNGEN

  pruef-kanal-besetzung.mjs (NEU, 16/0): baut die gemeldete Lage nach
  (Scout drin, kein Modi), laesst den Abgleich darueberlaufen und
  misst beide Richtungen. Vier Gegenproben: der von Hand Entfernte
  bleibt draussen; ein Scout kommt auch ueber die Mitglieder-Route
  nicht hinein; der Gast bleibt im Treff; und ein wieder
  hereingeschmuggelter Scout fliegt beim naechsten Abgleich erneut
  hinaus -- die Regel wirkt dauerhaft, nicht einmalig.

  pruef-chat-kanaele.mjs (79 mit 3 Fehlern -> 81/0): Die Zeile "wer
  nicht drin ist, sieht ihn nicht" hat den alten Zuschnitt
  festgeschrieben -- sie prueft jetzt die schaerfere Grenze: wer
  HERAUSGENOMMEN wurde, bleibt draussen, auch nach dem naechsten
  Abgleich. Dieser Weg war bis heute ungeprueft.

Mitgelaufen und gruen: pruef-chat, pruef-treffchat (110/0).

Vor dem Ausliefern gesichert: workspace-vor-kanalregel-20260922-231409.db
(946 KB, integrity_check ok, 17 Personen, 37 Teilnehmerzeilen,
111 Nachrichten).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 23:14:38 +02:00

660 lines
34 KiB
JavaScript

/* KATEGORIE-KANÄLE UND ANGEHEFTETE ANKÜNDIGUNGEN (10.09.2026)
Kapitel 7.2 des Anforderungsdokuments, die vier Sätze im Wortlaut:
"Team-Gruppenchat: für alle Mitglieder, vollständig einsehbar für
Owner & rechte Hand.
Kategorie-Kanäle: z. B. 'Clipping-Team', 'Live-Moderation' -
Owner/rechte Hand haben Zugriff auf alle Kanäle.
Private 1:1-Chats: ausschließlich für die beteiligten Personen
sichtbar - kein Zugriff durch Owner/rechte Hand.
Ankündigungs-Funktion: 'Pin'-Nachrichten von Owner/rechte Hand
an alle."
---------------------------------------------------------------------
DIE EINE PRÜFUNG, DERENTWEGEN ES DIESE DATEI GIBT
Der Zugriff auf Kanäle wurde in istDrin() eingebaut -- der Funktion,
durch die JEDER Verlauf, JEDER Anhang und JEDE Suche geht. Das ist
die richtige Stelle (sonst gäbe es die Regel in sechs von sieben
Routen), aber es ist auch die gefährlichste: Ein zu weit gefasstes
`return true` dort öffnet in EINEM Schritt jedes Gespräch im Haus.
Deshalb steht die Gegenprobe hier vorn und nicht als Anhängsel:
DogFather muss an einem Kanal vorbeikommen UND an einem
Zweier-Gespräch scheitern, das ihn nichts angeht. Eine Prüfung, die
nur das Erste zeigt, wäre grün, wenn die Regel gar nicht mehr
unterscheidet.
AUSSERDEM GEMESSEN:
* Ein Kanal je Zuständigkeit -- der zweite wird abgelehnt.
* Die rechte Hand kommt in einen Kanal, in den sie nie jemand
gesetzt hat (das ist der Sinn des Satzes im Dokument).
* Anheften: nur die Leitung, höchstens drei, und eine
zurückgenommene Nachricht fällt vom Aushang UND gibt den Platz frei.
* Eine Ankündigung von vor 300 Nachrichten steht immer noch oben.
(Der Verlauf liefert nur 200 Zeilen -- deshalb hat der Aushang
eine eigene Abfrage. Ohne diese Prüfung wäre der Grund dafür in
einem Kommentar begraben und die Abfrage beim nächsten Aufräumen
wegoptimiert.)
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-kanal-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Kanalpruefung");
process.env.PORT = `${PORT}`;
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
/* ---- Die Datenbank steht ZUERST in der ALTEN Form ------------------------
WARUM DIESE PRUEFUNG NICHT AUF EINER FRISCHEN ANLAGE LAEUFT: Auf einer
frischen steht 'kanal' schon im Bauplan, und die Umstellung tut
nichts. Auf dem echten Server steht `chat_raeume` aber in der alten
Form -- dort wird die Tabelle wirklich neu gebaut, mit Sicherung,
Zeilenzaehlung und Umbenennen. Das ist der gefaehrlichste Weg im
ganzen Haus, und er war bis heute nur auf dem Papier geprueft.
Der Index hier ist kein Beiwerk: `DROP TABLE` nimmt jeden Index mit,
und der Bauplan aus sqlite_master beschreibt nur die Tabelle. Genau
das ist die Zeile, die beweisen muss, dass er zurueckkommt. */
{
const { DatabaseSync: DB } = await import("node:sqlite");
const alt = new DB(process.env.WORKSPACE_DB);
alt.exec(`
CREATE TABLE chat_raeume (
id INTEGER PRIMARY KEY AUTOINCREMENT,
art TEXT NOT NULL DEFAULT 'direkt'
CHECK (art IN ('direkt','gruppe')),
name TEXT,
erstellt TEXT NOT NULL,
erstellt_von INTEGER,
letzte_am TEXT
);
CREATE INDEX IF NOT EXISTS idx_chat_raeume_letzte ON chat_raeume (letzte_am DESC);
INSERT INTO chat_raeume (art, name, erstellt, letzte_am)
VALUES ('gruppe','Von vorher','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z');
`);
alt.close();
}
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");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = `http://127.0.0.1:${PORT}`;
/* ---- Notbremse und eigener Zuhoerer -------------------------------------
BEIDES WURDE BEIM ERSTEN LAUF GEBRAUCHT, und zwar sofort.
Ein Zugriff auf `undefined[0]` liess diese Datei mitten im Lauf
abstuerzen. Zu sehen war davon: nichts. `index.js` haengt fuer den
BETRIEB ein Auffangnetz an `uncaughtException` -- dort voellig
richtig, ein Fehler in einer Nebensache darf die Website nicht
offline nehmen. Eine Pruefung importiert dieselbe Datei in DENSELBEN
Prozess und erbt es: Der Absturz wurde nur notiert, der Server hielt
den Prozess am Leben, und von aussen sah es fuenf Minuten lang aus
wie "laeuft noch".
`process.on` ERGAENZT, es ersetzt nicht -- der Zuhoerer unten laeuft
also zusaetzlich zu dem aus index.js und beendet den Lauf wirklich.
Die Notbremse faengt den anderen Fall: nicht Absturz, sondern
Warten. `unref()`, damit sie einen sauberen Lauf nie aufhaelt. */
process.on("uncaughtException", (f) => {
console.error(""); console.error("ABBRUCH durch einen Absturz:", f?.stack || f);
process.exit(1);
});
process.on("unhandledRejection", (f) => {
console.error(""); console.error("ABBRUCH durch ein unbehandeltes Versprechen:", f?.stack || f);
process.exit(1);
});
const notbremse = setTimeout(() => {
console.error(""); console.error("ABBRUCH: Die Pruefung laeuft seit 120 s. Die letzte Zeile oben"
+ " ist die Stelle, an der sie stehengeblieben ist.");
process.exit(1);
}, 120000);
notbremse.unref?.();
let fehler = 0;
let geprueft = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* ---- Bestand ------------------------------------------------------------
DogFather, seine rechte Hand und drei Modis. Drei und nicht zwei:
Fuer das Zweier-Gespraech, das DogFather nichts angeht, braucht es
zwei, die NICHT er sind -- und fuer die Besetzung eines Kanals noch
einen dritten, den man hinein- und wieder herausnehmen kann. */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idHand = anlegen("Vanessa", "hand", "CODE-HAND-0001");
const idEins = anlegen("Ayla", "modi", "CODE-TEAM-0001");
const idZwei = anlegen("Ben", "modi", "CODE-TEAM-0002");
const idDrei = anlegen("Cem", "modi", "CODE-TEAM-0003");
/* Die Agenturseite des Hauses. Sie kommt in dieser Pruefung nur einmal
vor -- aber an der Stelle, die ohne sie unbewiesen bliebe. */
anlegen("Cigdem", "manager", "CODE-MANA-0001");
anlegen("VanVan", "spicy", "CODE-SPIC-0001");
/* DIE ANMELDUNG KLOPFT AN DIE CREW-WAND -- und das ist kein Kniff,
sondern die Regel, die am 10.09.2026 auf Filipes Wunsch entstanden
ist: "auf der workspace seite fuer die agentur und so sollen die
modis von mir nicht mehr rein kommen. die sollen nur noch ueber die
crew seite rein kommen." Wer sie an der Agenturwand versucht,
bekommt 401 -- ohne Hinweis, absichtlich.
Beim ersten Anlauf stand hier ein blankes `fetch` auf 127.0.0.1.
Ergebnis: DogFather kam herein, die rechte Hand und alle drei
Teammitglieder nicht, und die halbe Pruefung meldete 401 statt der
Sache, die sie messen wollte. Die Sperre hat also getan, was sie
soll -- sie hat nur zuerst mich erwischt.
WARUM node:http UND NICHT fetch: `fetch` (undici) setzt den
Host-Kopf selbst aus der Adresse und ignoriert einen mitgegebenen --
nachgemessen, nicht vermutet. Fuer alles danach genuegt der Keks:
sitzungPasstZurAdresse() laesst 127.0.0.1 unberuehrt. */
const { request: httpAnfrage } = await import("node:http");
function anmelden(rolle, code, wand = "crew.dogfather-universe.com") {
return new Promise((fertig) => {
const rumpf = JSON.stringify({ rolle, code });
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: "/workspace/api/anmelden", method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(rumpf),
Host: wand,
},
}, (antwort) => {
antwort.resume();
antwort.on("end", () => fertig({
ok: antwort.statusCode === 200,
keks: (antwort.headers["set-cookie"] || []).map((k) => k.split(";")[0]).join("; "),
}));
});
a.on("error", () => fertig({ ok: false, keks: "" }));
a.end(rumpf);
});
}
const rufe = (keks, weg, koerper, methode = "GET") =>
fetch(BASIS + weg, {
method: methode,
headers: { "Content-Type": "application/json", Cookie: keks },
body: koerper === undefined ? undefined : JSON.stringify(koerper),
});
const holen = async (keks, weg) => (await rufe(keks, weg)).json();
const dogi = await anmelden("admin", "CODE-DOGI-0001");
const hand = await anmelden("hand", "CODE-HAND-0001");
const eins = await anmelden("modi", "CODE-TEAM-0001");
const zwei = await anmelden("modi", "CODE-TEAM-0002");
const drei = await anmelden("modi", "CODE-TEAM-0003");
ok([dogi, hand, eins, zwei, drei].every((x) => x.ok && x.keks),
"alle fuenf sind angemeldet");
/* =======================================================================
0. Die Umstellung der alten Tabelle
======================================================================= */
console.log("");
console.log("=== Die alte Tabelle wurde umgebaut ===");
{
const plan = d.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'chat_raeume'").get()?.sql || "";
const regel = plan.match(/art\s+IN\s*\(([^)]*)\)/i)?.[1] || "";
ok(regel.includes("'kanal'"), `die CHECK-Regel kennt jetzt 'kanal' (${regel})`);
ok(regel.includes("'direkt'") && regel.includes("'gruppe'"),
"und die beiden alten Arten stehen noch darin");
const spalten = d.prepare("PRAGMA table_info(chat_raeume)").all().map((z) => z.name);
ok(spalten.includes("kategorie"), `die Spalte kategorie ist dazugekommen (${spalten.length} Spalten)`);
/* DIE ZEILE VON VORHER. Ohne sie waere "umgebaut" auch dann gruen,
wenn die Tabelle einfach neu und leer waere. */
const alt = d.prepare("SELECT name FROM chat_raeume WHERE name = 'Von vorher'").get();
ok(!!alt, "die Zeile von vorher steht noch da");
/* UND DER INDEX. Er wurde vor dem Umbau angelegt, `DROP TABLE` hat
ihn mitgenommen, und die Umstellung muss ihn zurueckgebracht
haben. */
const idx = d.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'chat_raeume'")
.all().map((z) => z.name);
ok(idx.includes("idx_chat_raeume_letzte"),
`der Index hat den Umbau ueberlebt (${idx.join(", ") || "keiner"})`);
ok(idx.includes("idx_chat_kanal_kategorie"),
"und der neue eindeutige Index steht auch da");
}
/* =======================================================================
1. Einen Kanal anlegen -- und wer das darf
======================================================================= */
console.log("\n=== Kanaele anlegen ===");
let kanalId = 0;
{
const a = await rufe(dogi.keks, "/workspace/api/chat/kanal",
{ kategorie: "clipping", mit: [idEins, idZwei] }, "POST");
const antwort = await a.json();
kanalId = antwort.raum_id;
ok(a.status === 201 && kanalId > 0, `DogFather legt einen Kanal an (${a.status})`);
ok(antwort.name === "Clipping",
`der Name kommt aus der Kategorie ("${antwort.name}")`);
/* GEGENPROBE ZUR REGEL, nicht nur zur Sperre: Eine zweite Kategorie
muss gehen. Ginge sie nicht, waere die 409 unten wertlos -- sie
koennte auch heissen "Kanaele gehen ueberhaupt nicht mehr". */
const b = await rufe(dogi.keks, "/workspace/api/chat/kanal", { kategorie: "technik" }, "POST");
ok(b.status === 201, `eine zweite Zustaendigkeit geht (${b.status})`);
const c = await rufe(dogi.keks, "/workspace/api/chat/kanal", { kategorie: "clipping" }, "POST");
ok(c.status === 409, `dieselbe Zustaendigkeit ein zweites Mal: abgelehnt (${c.status})`);
const anzahl = d.prepare(
"SELECT COUNT(*) AS n FROM chat_raeume WHERE art = 'kanal' AND kategorie = 'clipping'").get().n;
ok(anzahl === 1, `und es steht wirklich nur einer in der Datenbank (${anzahl})`);
const e = await rufe(dogi.keks, "/workspace/api/chat/kanal", { kategorie: "gibtsnicht" }, "POST");
ok(e.status === 400, `eine erfundene Kategorie wird abgelehnt (${e.status})`);
/* Wer keine Kanaele fuehrt, soll nicht einmal erfahren, dass es sie
gibt -- deshalb 404 und nicht 403. */
const f = await rufe(eins.keks, "/workspace/api/chat/kanal", { kategorie: "community" }, "POST");
ok(f.status === 404, `ein Teammitglied kann keinen anlegen (${f.status})`);
const g = d.prepare("SELECT COUNT(*) AS n FROM chat_raeume WHERE kategorie = 'community'").get().n;
ok(g === 0, "und es ist auch keiner entstanden");
}
/* =======================================================================
2. Wer sieht den Kanal?
======================================================================= */
console.log("\n=== Wer sieht ihn ===");
{
const raeumeVon = async (keks) => (await holen(keks, "/workspace/api/chat/raeume")).raeume || [];
const beiDogi = await raeumeVon(dogi.keks);
const k = beiDogi.find((r) => r.id === kanalId);
ok(!!k, "DogFather hat den Kanal in seiner Liste");
ok(k?.art === "kanal" && k?.kategorie === "clipping",
`er kommt als Kanal mit seiner Kategorie an (${k?.art}/${k?.kategorie})`);
/* DER SATZ AUS DEM DOKUMENT. Die rechte Hand wurde NIRGENDS
eingetragen -- sie war beim Anlegen nicht dabei und steht in
keiner Auswahl. Trotzdem muss der Kanal bei ihr stehen. */
const beiHand = await raeumeVon(hand.keks);
ok(beiHand.some((r) => r.id === kanalId),
"die rechte Hand sieht ihn, ohne je hinzugefuegt worden zu sein");
const lesen = await rufe(hand.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
ok(lesen.status === 200, `und kommt in den Verlauf (${lesen.status})`);
const beiEins = await raeumeVon(eins.keks);
ok(beiEins.some((r) => r.id === kanalId), "wer hineingesetzt wurde, sieht ihn auch");
/* =================================================================
GEGENPROBE: WER DRAUSSEN IST, BLEIBT DRAUSSEN
=================================================================
HIER STAND ETWAS ANDERES (bis 22.09.2026 abends): "Der dritte ist
NICHT drin, weil ihn niemand eingetragen hat."
Das galt, solange ein Kategorie-Kanal nur die enthielt, die man
von Hand hineingesetzt hatte. Genau daran ist VanVan haengen
geblieben: Im Moderations-Kanal standen zwei SCOUTS und kein
einziger Modi -- die Modis sahen den Kanal gar nicht und konnten
dort niemanden erwaehnen.
Filipe: „Die Modis zum Erwaehnen hinzufuegen." /
„Die Modis sehen den Chat Moderation auch gar nicht."
Seither gilt: Ein Kategorie-Kanal gehoert dem ganzen Team Dogi.
Es gibt auch keine Handhabe fuer etwas anderes -- `kategorienFuer`
gibt JEDEM aus Team Dogi ALLE Kategorien; eine Zuordnung Person ->
Kategorie existiert nirgends. Eine Regel "nur die Zustaendigen"
waere also eine Liste, die niemand pflegt.
DIE GRENZE GIBT ES WEITERHIN, sie faengt nur an einer anderen
Stelle an: Wer ueber die Mitglieder-Route HERAUSGENOMMEN wurde,
bleibt draussen -- auch ueber die Nummer. Das ist die schaerfere
Messung: Vorher wurde jemand geprueft, den nie jemand eingetragen
hat; jetzt einer, der drin WAR. Der Weg dorthin (raus_am, und der
Abgleich holt ihn nicht zurueck) war bis heute ungeprueft. */
const raus = await rufe(dogi.keks, `/workspace/api/chat/kanal/${kanalId}/mitglieder`,
{ mit: [idEins, idZwei] }, "PUT");
ok(raus.status === 200, `der dritte wird herausgenommen (${raus.status})`);
const beiDrei = await raeumeVon(drei.keks);
ok(!beiDrei.some((r) => r.id === kanalId), "wer herausgenommen wurde, sieht ihn nicht mehr");
const versuch = await rufe(drei.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
ok(versuch.status === 404, `und kommt auch ueber die Nummer nicht hinein (${versuch.status})`);
/* UND DER ABGLEICH HOLT IHN NICHT ZURUECK. Ohne diese Zeile waere
die Sperre oben nur ein Augenblick: Der naechste Blick in die
Gespraechsliste stellt sie wieder her -- oder eben nicht. */
await raeumeVon(dogi.keks);
const nochDraussen = await raeumeVon(drei.keks);
ok(!nochDraussen.some((r) => r.id === kanalId),
"und der naechste Abgleich holt ihn nicht zurueck");
const wahl = await holen(dogi.keks, "/workspace/api/chat/raeume");
ok(wahl.darf_kanal === true, "DogFather bekommt das Ja zum Anlegen");
ok(Array.isArray(wahl.kanal_frei) && !wahl.kanal_frei.some((x) => x.wert === "clipping"),
"die belegte Zustaendigkeit steht nicht mehr zur Auswahl");
const wahlModi = await holen(eins.keks, "/workspace/api/chat/raeume");
ok(wahlModi.darf_kanal === false && wahlModi.kanal_frei.length === 0,
"ein Teammitglied bekommt weder das Ja noch die Liste");
}
/* =======================================================================
3. DIE WICHTIGSTE PRÜFUNG: Die Ausnahme bleibt bei den Kanaelen
======================================================================= */
console.log("\n=== Und was NICHT offensteht ===");
{
/* Ein Zweier-Gespraech und eine Gruppe, beide OHNE DogFather. Direkt
in die Datenbank gesetzt und nicht ueber die Schnittstelle: Diese
Pruefung gilt der Zugriffsregel, nicht dem Anlegeweg -- und ein
Anlegeweg, der sich aendert, duerfte sie nicht mit umwerfen. */
function raumMit(art, name, leute) {
d.prepare("INSERT INTO chat_raeume (art, name, erstellt, letzte_am) VALUES (?,?,?,?)")
.run(art, name, jetzt, jetzt);
const id = d.prepare("SELECT last_insert_rowid() AS id").get().id;
for (const p of leute) {
d.prepare("INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)")
.run(id, p, jetzt);
}
d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)")
.run(id, leute[0], "Etwas, das niemanden sonst angeht.", jetzt);
return id;
}
const privat = raumMit("direkt", null, [idEins, idZwei]);
const gruppe = raumMit("gruppe", "Unter uns", [idEins, idZwei, idDrei]);
for (const [wer, keks] of [["DogFather", dogi.keks], ["die rechte Hand", hand.keks]]) {
const a = await rufe(keks, `/workspace/api/chat/raeume/${privat}/nachrichten`);
ok(a.status === 404, `${wer} kommt NICHT in ein fremdes Zweier-Gespraech (${a.status})`);
const b = await rufe(keks, `/workspace/api/chat/raeume/${gruppe}/nachrichten`);
ok(b.status === 404, `${wer} kommt NICHT in eine fremde Gruppe (${b.status})`);
const c = await rufe(keks, `/workspace/api/chat/raeume/${privat}/nachrichten`,
{ text: "hallo" }, "POST");
ok(c.status === 404, `${wer} kann dort auch nichts hineinschreiben (${c.status})`);
const liste = (await holen(keks, "/workspace/api/chat/raeume")).raeume || [];
ok(!liste.some((r) => r.id === privat || r.id === gruppe),
`und beides steht nicht in der Liste von ${wer}`);
}
/* GEGENPROBE: Die Beteiligten kommen sehr wohl hinein. Ohne sie
koennte die Sperre oben auch "diese Raeume gibt es gar nicht"
heissen. */
const drin = await rufe(eins.keks, `/workspace/api/chat/raeume/${privat}/nachrichten`);
ok(drin.status === 200, `wer dabei ist, kommt hinein (${drin.status})`);
}
/* =======================================================================
4. Besetzung aendern
======================================================================= */
console.log("\n=== Wer ist im Kanal ===");
{
/* Vorher steht der dritte draussen (Abschnitt 2 hat ihn
herausgenommen) und der zweite drin. Diese Runde dreht genau das
um: der dritte kommt zurueck, der zweite geht. Die Zahlen sind
damit nicht geraten, sondern die Folge des Standes davor. */
const a = await rufe(dogi.keks, `/workspace/api/chat/kanal/${kanalId}/mitglieder`,
{ mit: [idEins, idDrei] }, "PUT");
const antwort = await a.json();
ok(a.status === 200 && antwort.dazu === 1 && antwort.raus === 1,
`der Herausgenommene kommt zurueck, ein anderer geht (${antwort.dazu}/${antwort.raus})`);
const beiDrei = (await holen(drei.keks, "/workspace/api/chat/raeume")).raeume || [];
ok(beiDrei.some((r) => r.id === kanalId), "der Neue sieht den Kanal jetzt");
const raus = await rufe(zwei.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
ok(raus.status === 404, `wer herausgenommen wurde, kommt nicht mehr hinein (${raus.status})`);
/* NIEMAND VERSCHWINDET RUECKWIRKEND. Die Zeile bleibt mit Datum
stehen -- geloescht wird sie nicht. */
const zeile = d.prepare(
"SELECT raus_am FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?").get(kanalId, idZwei);
ok(!!zeile && !!zeile.raus_am, "die Zeile bleibt stehen, mit Austrittsdatum");
/* DIE LEITUNG LAESST SICH NICHT HINAUSDRAENGEN. Eine leere Liste
muesste alle Nicht-Leitungen entfernen und die Leitung lassen. */
const b = await rufe(dogi.keks, `/workspace/api/chat/kanal/${kanalId}/mitglieder`,
{ mit: [] }, "PUT");
ok(b.status === 200, `eine leere Besetzung ist erlaubt (${b.status})`);
const nachher = (await holen(dogi.keks, "/workspace/api/chat/raeume")).raeume || [];
ok(nachher.some((r) => r.id === kanalId), "DogFather ist immer noch drin");
const handNachher = (await holen(hand.keks, "/workspace/api/chat/raeume")).raeume || [];
ok(handNachher.some((r) => r.id === kanalId), "die rechte Hand auch");
/* Und wieder zurueck -- fuer den Rest der Pruefung wird jemand
gebraucht, der drin ist, aber nichts anheften darf. */
await rufe(dogi.keks, `/workspace/api/chat/kanal/${kanalId}/mitglieder`,
{ mit: [idEins] }, "PUT");
const wieder = await rufe(eins.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
ok(wieder.status === 200, `zurueckgeholt kommt er wieder hinein (${wieder.status})`);
const c = await rufe(eins.keks, `/workspace/api/chat/kanal/${kanalId}/mitglieder`,
{ mit: [idEins, idZwei] }, "PUT");
ok(c.status === 404, `ein Teammitglied kann die Besetzung nicht aendern (${c.status})`);
/* Und diese Route fasst KEINE Gruppen an -- sonst waere sie ein
Werkzeug, um fremde Runden umzubesetzen. */
const gruppeId = d.prepare(
"SELECT id FROM chat_raeume WHERE art = 'gruppe' ORDER BY id DESC LIMIT 1").get()?.id;
const g = await rufe(dogi.keks, `/workspace/api/chat/kanal/${gruppeId}/mitglieder`,
{ mit: [idDogi] }, "PUT");
ok(g.status === 404, `sie greift nicht auf eine Gruppe durch (${g.status})`);
}
/* =======================================================================
5. Ankuendigungen anheften
======================================================================= */
console.log("\n=== Anheften ===");
{
async function schreiben(keks, text) {
const a = await rufe(keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`, { text }, "POST");
return (await a.json())?.nachricht?.id || 0;
}
const n1 = await schreiben(dogi.keks, "Heute 20 Uhr Live, bitte alle da.");
ok(n1 > 0, `eine Nachricht im Kanal (#${n1})`);
const a = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n1}/anheften`, { an: true }, "PUT");
ok(a.status === 200, `DogFather heftet sie an (${a.status})`);
const verlauf = await holen(eins.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
ok((verlauf.angeheftet || []).some((x) => x.id === n1),
"sie steht bei ALLEN im Kanal oben, nicht nur bei ihm");
ok(verlauf.angeheftet[0]?.heftete === "Filipe",
`und es steht dabei, wer sie angeheftet hat (${verlauf.angeheftet[0]?.heftete})`);
ok(verlauf.darf_anheften === false, "ein Teammitglied bekommt kein Ja zum Anheften");
ok((verlauf.nachrichten || []).find((x) => x.id === n1)?.angeheftet === true,
"auch im Verlauf ist sie als angeheftet erkennbar");
const b = await rufe(eins.keks, `/workspace/api/chat/nachrichten/${n1}/anheften`, { an: true }, "PUT");
ok(b.status === 403, `ein Teammitglied darf nicht anheften (${b.status})`);
/* HOECHSTENS DREI. Der vierte muss abgelehnt werden -- und die
Gegenprobe steckt in den drei davor: Gingen die auch nicht, waere
die 409 nichtssagend. */
const n2 = await schreiben(dogi.keks, "Zweite Ansage.");
const n3 = await schreiben(dogi.keks, "Dritte Ansage.");
const n4 = await schreiben(dogi.keks, "Vierte Ansage.");
const r2 = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n2}/anheften`, { an: true }, "PUT");
const r3 = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n3}/anheften`, { an: true }, "PUT");
ok(r2.status === 200 && r3.status === 200, "zwei weitere gehen");
const r4 = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n4}/anheften`, { an: true }, "PUT");
ok(r4.status === 409, `die vierte wird abgelehnt (${r4.status})`);
/* ZURUECKNEHMEN LOEST DEN AUSHANG -- und gibt den Platz frei. Der
zweite Teil ist der wichtigere: Ohne ihn zaehlte die Nachricht
weiter mit, und niemand kaeme darauf, warum sich nichts mehr
anheften laesst. */
const weg = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n3}`, undefined, "DELETE");
ok(weg.status === 200, `die dritte wird zurueckgenommen (${weg.status})`);
const nachWeg = await holen(dogi.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
ok(!(nachWeg.angeheftet || []).some((x) => x.id === n3), "sie faellt vom Aushang");
const r4b = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n4}/anheften`, { an: true }, "PUT");
ok(r4b.status === 200, `und der Platz ist wieder frei (${r4b.status})`);
const los = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n1}/anheften`, { an: false }, "PUT");
ok(los.status === 200, `loesen geht (${los.status})`);
const nachLos = await holen(dogi.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
ok(!(nachLos.angeheftet || []).some((x) => x.id === n1), "und sie ist oben verschwunden");
/* Wer gar nicht im Raum ist, bekommt 404 -- nicht 403. Sonst liesse
sich durch Ausprobieren herausfinden, welche Nachrichten es gibt. */
const fremd = await rufe(drei.keks, `/workspace/api/chat/nachrichten/${n2}/anheften`, { an: false }, "PUT");
ok(fremd.status === 404, `wer nicht im Raum ist, sieht die Nachricht nicht (${fremd.status})`);
}
/* =======================================================================
6. Eine Ankuendigung von vor 300 Nachrichten
======================================================================= */
console.log("\n=== Der Aushang haelt laenger als der Verlauf ===");
{
const alt = d.prepare(
"SELECT id FROM chat_nachrichten WHERE raum_id = ? ORDER BY id LIMIT 1").get(kanalId).id;
d.prepare("UPDATE chat_nachrichten SET angeheftet_am = ?, angeheftet_von = ? WHERE id = ?")
.run(jetzt, idDogi, alt);
for (let i = 0; i < 300; i++) {
d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)")
.run(kanalId, idDogi, `Zeile ${i}`, jetzt);
}
const v = await holen(dogi.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
ok((v.nachrichten || []).length === 200,
`der Verlauf liefert 200 Zeilen (${(v.nachrichten || []).length})`);
ok(!(v.nachrichten || []).some((x) => x.id === alt),
"die alte Nachricht ist aus dem Verlauf herausgefallen");
ok((v.angeheftet || []).some((x) => x.id === alt),
"und steht trotzdem noch oben -- genau dafuer hat der Aushang eine eigene Abfrage");
}
/* =======================================================================
7. Weggeraeumt heisst weggeraeumt -- auch fuer den Aushang
======================================================================= */
console.log("\n=== Wer wegraeumt, sieht auch den Aushang nicht mehr ===");
{
const vorher = await holen(eins.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
ok((vorher.angeheftet || []).length > 0, "vorher steht etwas oben");
const weg = await rufe(eins.keks, `/workspace/api/chat/raeume/${kanalId}`, undefined, "DELETE");
ok(weg.status === 200, `das Teammitglied raeumt den Kanal weg (${weg.status})`);
const nachher = await holen(eins.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
ok((nachher.angeheftet || []).length === 0,
`bei ihm ist der Aushang jetzt leer (${(nachher.angeheftet || []).length})`);
const beiDogi = await holen(dogi.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
ok((beiDogi.angeheftet || []).length > 0,
"bei DogFather steht er unveraendert -- weggeraeumt wird nur bei einem selbst");
}
/* =======================================================================
8. Wer steht ueberhaupt in der Personenauswahl?
=======================================================================
WARUM DAS HIER STEHT, und zwar seit heute: Bis eben zeichnete die
Auswahl im Browser nur die fuenf Rollen aus bereiche.js. Team Dogi
steht dort nicht (der Rollenname gehoert in keine Datei, die jeder
herunterlaedt) -- und wurde deshalb NICHT GEZEICHNET. Das war ein
Fehler (DogFather konnte niemandem aus seinem Team schreiben), aber
es war nebenbei auch ein Netz: Selbst wenn der Server jemanden
mitgeschickt haette, den man nicht sehen soll, waere er unsichtbar
geblieben.
Dieses Netz ist jetzt weg -- die Auswahl zeichnet, was ankommt.
Also muss bewiesen sein, dass beim Falschen nichts ankommt. Genau
diese Sorte Nebeneffekt ist gemeint, wenn im Haus steht: eine
Zustaendigkeit, die nur zufaellig aus einer anderen Regel
herausfaellt, faellt beim naechsten Umbau zufaellig wieder heraus.
======================================================================= */
console.log("");
console.log("=== Die Personenauswahl ===");
{
/* Manager und Spicy Media melden sich an der AGENTURWAND an -- an der
Crew-Wand haetten sie gar keine Kachel. Zwei Waende, zwei Tueren;
die Pruefung benutzt beide, weil das Haus beide hat. */
const cigdem = await anmelden("manager", "CODE-MANA-0001", "workspace.dogfather-universe.com");
const vanvan = await anmelden("spicy", "CODE-SPIC-0001", "workspace.dogfather-universe.com");
ok(cigdem.ok && vanvan.ok, "Manager und Spicy Media kommen ueber die Agenturwand herein");
const namen = async (keks) => {
const { personen } = await holen(keks, "/workspace/api/chat/partner");
return (personen || []).map((p) => p.name);
};
const teamNamen = ["Ayla", "Ben", "Cem", "Vanessa"];
const beiDogi = await namen(dogi.keks);
ok(teamNamen.every((n) => beiDogi.includes(n)),
`DogFather hat sein Team in der Auswahl (${beiDogi.join(", ")})`);
const beiCigdem = await namen(cigdem.keks);
ok(!teamNamen.some((n) => beiCigdem.includes(n)),
`ein Manager hat es NICHT (${beiCigdem.join(", ") || "leer"})`);
ok(beiCigdem.length > 0, "und seine Auswahl ist trotzdem nicht leer");
const beiVanVan = await namen(vanvan.keks);
ok(!teamNamen.some((n) => beiVanVan.includes(n)),
`Spicy Media hat es NICHT (${beiVanVan.join(", ") || "leer"})`);
ok(beiVanVan.includes("Cigdem"), "sieht aber die Agentur");
/* NIEMAND STEHT IN SEINER EIGENEN AUSWAHL. Bei Spicy Media stand er
es doch -- siehe schreibbareIds() in workspace.js. Der Fall
entsteht nur, wenn es jemanden zu verbergen gibt, und genau den
stellt diese Pruefung her. */
ok(!beiVanVan.includes("VanVan"), "und nicht sich selbst");
ok(!beiDogi.includes("Filipe"), "DogFather ebenfalls nicht");
ok(!beiCigdem.includes("Cigdem"), "der Manager auch nicht");
/* DIE UEBERSCHRIFT KOMMT MIT. Ohne sie faellt der Browser auf
"Weitere" zurueck -- richtig gezeichnet, aber ohne Auskunft. */
const roh = await holen(dogi.keks, "/workspace/api/chat/partner");
const ayla = (roh.personen || []).find((p) => p.name === "Ayla");
ok(!!ayla?.gruppe, `jede Person bringt ihre Ueberschrift mit ("${ayla?.gruppe}")`);
}
/* =======================================================================
9. Eine Quelle fuer die Zugriffsfrage
======================================================================= */
console.log("\n=== Kein Aufruf mit blanker Nummer ===");
{
/* istDrin() braucht seit heute die PERSON, weil die Antwort an der
Rolle haengt. Ein Aufruf mit `.id` liefe in den TypeError -- aber
erst dann, wenn jemand genau diese Route benutzt. Hier faellt es
sofort auf.
GEGENPROBE zur Suche selbst: Sie muss ein absichtlich falsches
Muster erkennen. Sonst waere sie gruen, weil sie nie etwas findet. */
const quelle = readFileSync(new URL("./workspace-chat.js", import.meta.url), "utf8");
const muster = /istDrin\([^)]*\.id\s*\)/g;
const treffer = quelle.match(muster) || [];
ok(treffer.length === 0, `kein Aufruf uebergibt nur die Nummer (${treffer.join(", ") || "keiner"})`);
const aufrufe = (quelle.match(/istDrin\(/g) || []).length;
ok(aufrufe >= 8, `und es gibt wirklich Aufrufe zu pruefen (${aufrufe})`);
ok("istDrin(raumId, req.person.id)".match(muster)?.length === 1,
"die Suche erkennt einen falschen Aufruf");
}
d.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log(`\n${fehler === 0 ? "BESTANDEN" : "FEHLGESCHLAGEN"} -- ${geprueft} geprueft, ${fehler} Fehler`);
/* process.exit(), wie in pruef-chat: Der Express-Server und die
offenen Ereignisstroeme halten den Prozess sonst am Leben. */
process.exit(fehler ? 1 : 0);