Files
dogfather-universe/server/pruef-chat-kanaele.mjs
T
DogFatherGitandClaude Opus 5 6e46a08543 Portnummern werden abgeleitet, nicht mehr vergeben
Gemessen: 165 Pruefdateien, 129 verschiedene Nummern -- NEUNZEHN
doppelt, vier davon dreifach. Niemand hatte das gewollt; jede neue
Pruefung wurde von einer vorhandenen abgeschrieben, und die Nummer kam
mit. Meine eigene Notiz sagte "sieben" -- auch eine Bestandsliste
altert.

Der Waechter faengt den Schaden ab, aber er kann nur melden, was schon
passiert ist: Zwei Pruefungen mit derselben Nummer koennen nie
gleichzeitig laufen, und ein liegengebliebener Prozess der einen laesst
die andere abbrechen mit einer Meldung, die wie ein Befund aussieht.
Genau das ist mir am 20.09. zweimal passiert.

Jetzt leitet jede Datei ihre Nummer aus ihrer STELLE IM ALPHABET ab
(eigenerPort in helfer-port.mjs), zwei je Datei. Nicht ueber eine
Pruefsumme: Bei 165 Namen in 4900 Nummern waeren nach dem
Geburtstagsproblem rund DREI Zusammenstoesse zu erwarten -- ein Hash
tauscht eine sichtbare Doppelung gegen eine unsichtbare. Die Stelle im
Alphabet ist eindeutig von der Bauart her.

--- ZWEI FEHLER AUF DEM WEG, BEIDE LEHRREICH -------------------------

1. DER ERSTE VERSUCH WAR GRUEN UND KAPUTT. Ersetzt wurde mit einem
   Muster: "([^"]*4231[^"]*)". Das hielt

     { host: "127.0.0.1", port: 4231, path: "/404.html" }

   fuer eine Zeichenkette -- ein Muster kann eine oeffnende nicht von
   einer schliessenden Anfuehrung unterscheiden. Heraus kam

     { host: "127.0.0.1`, port: ${PORT}, path: `/404.html" }

   also GUELTIGER Code ohne port-Feld. `node --check` sagte gruen fuer
   alle 164 Dateien. Aufgefallen ist es erst, weil ich vier Vertreter
   gegen eine vorher gemessene Grundlinie laufen liess: pruef-schranke
   39/0 vorher, 38/1 nachher.

   Alles zurueckgenommen und mit einem Zerleger neu gemacht, der weiss,
   ob eine Stelle Code, Zeichenkette, Vorlage, Kommentar oder
   regulaerer Ausdruck ist. In pruef-ics stand die Nummer in einem
   regulaeren Ausdruck -- der wird jetzt gebaut statt hingeschrieben.

2. EIN MODUL, DAS BEIM IMPORTIEREN ARBEITET, IST EINE FALLE. Der
   zweite Durchgang importierte den ersten, um seine Mechanik zu
   benutzen -- und fuehrte dessen Hauptlauf gleich mit aus. Die
   zweiten Nummern wurden dadurch als erste behandelt, zwei Aufrufe
   bekamen dieselbe Nummer, und in pruef-content stand `const PORT`
   zweimal.

--- WAS DAS DAUERHAFT HAELT -----------------------------------------

pruef-portnummern.mjs (neu, 8 Pruefungen) fragt nicht "welche Nummern
sind doppelt", sondern "wer traegt ueberhaupt noch eine von Hand ein"
und "wer startet einen Server, ohne seine Nummer abzuleiten". Die
zweite Frage hat sofort etwas gefunden, das in KEINER Doppelungsliste
stand: pruef-push-weg belegte 4341 und 4342, rief den Waechter aber
gar nicht auf -- dieselbe Nummer wie pruef-agentur. Eine Liste zeigt
nur, was auf ihr steht.

Mit Gegenprobe: Eine unbekannte Datei bekommt keine geratene Nummer,
sondern einen Abbruch, und eine dritte Nummer je Datei gibt es nicht.

--- NACHGEMESSEN ----------------------------------------------------

Sechzehn Pruefungen gegen ihre vorher gemessene Grundlinie, je eine
Vertreterin jeder umgestellten Bauweise (eine Nummer, zwei Nummern,
Nummer in einer Zeichenkette, in einer Vorlage, in einem regulaeren
Ausdruck, dynamische Einfuhr, Nachtlauf mit zwei Laeufen):

  crew-adresse 132/0 · schranke 39/0 · content 45/0 · entwicklung 46/0
  anruf 127/0 · ics 37/0 · push-weg 20/0 · arten 28/0 · video 67/0
  kanalzeile 14/0 · agentur 62/0 · spicy 83/0 · creator-anlegen 50/0
  push-ziel 10/0 · portnummern 8/0

Alle exakt wie vorher. Zwei waren schon vorher rot und sind es
unveraendert geblieben (crew-wand-bild 41/4, teilen 14/3) -- per
`git stash` belegt, nicht angenommen.

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

620 lines
32 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: Der dritte ist NICHT drin. Er darf ihn weder in der
Liste haben noch ueber die Nummer erreichen. */
const beiDrei = await raeumeVon(drei.keks);
ok(!beiDrei.some((r) => r.id === kanalId), "wer nicht drin ist, sieht ihn nicht");
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})`);
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 ===");
{
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,
`einer kommt dazu, einer 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);