Files
dogfather-universe/server/pruef-chat-kanaele.mjs
T
DogFatherGitandClaude Opus 5 d92ba76e33 Kategorie-Kanaele und angeheftete Ankuendigungen (Kapitel 7.2)
Die vier Saetze aus dem Anforderungsdokument, der Reihe nach: Team-
Gruppenchat, Kategorie-Kanaele mit Zugriff fuer Owner und rechte Hand,
private 1:1-Chats OHNE diesen Zugriff, und Pin-Nachrichten an alle.

EIN KANAL IST KEINE VIERTE TABELLE, sondern eine dritte Art Raum
(`art = 'kanal'` neben 'direkt' und 'gruppe'). Damit gilt fuer ihn ohne
eine einzige neue Zeile alles, was schon da ist: Verlauf, Anhaenge,
Suche, Ungelesen-Zaehler, Live-Strom, Wegraeumen. Eine eigene Tabelle
haette all das ein zweites Mal gebraucht -- und die zweite Fassung waere
die gewesen, in der die Zugriffsregel fehlt.

Welche Zustaendigkeit, kommt aus MODI_KATEGORIEN -- derselben Liste, aus
der auch die Aufgaben ihre Kategorie nehmen. Der NAME kommt aus der
Kategorie und ist kein freies Feld: "Clipping" neben "Clipping-Team"
waeren zwei halbe Verlaeufe, und man merkt es erst, wenn jemand die
Antwort im falschen sucht. Ein eindeutiger Teilindex haelt das auch
dann fest, wenn zwei Anfragen im selben Augenblick ankommen.

DIE ZUGRIFFSREGEL STEHT IN istDrin() -- der Funktion, durch die alle
sieben lesenden und schreibenden Wege gehen. In den Routen stuende sie
in sechs davon und in der siebten nicht. Sie gilt AUSDRUECKLICH nur
fuer 'kanal': Zweier-Gespraeche bleiben zu, auch fuer DogFather (so
steht es im Dokument), und Gruppen ebenfalls -- wer eine Gruppe
aufmacht, hat sich fuer einen geschlossenen Kreis entschieden, und den
nachtraeglich still zu oeffnen waere das Gegenteil dessen, was er getan
hat. Wenn Filipe das anders will, ist es eine Zeile -- aber es waere
seine Entscheidung und muesste fuer die Beteiligten SICHTBAR sein.

istDrin() nimmt dafuer die PERSON statt ihrer Nummer und wirft bei
einer Nummer einen Fehler, statt stillschweigend "nein" zu antworten.

HOECHSTENS DREI ANKUENDIGUNGEN je Raum. Nicht eine (Regeln, Live-Plan
und Frist muessen gleichzeitig oben stehen koennen) und nicht beliebig
viele -- eine Pinnwand, die scrollt, ist ein zweiter Verlauf. Der
Aushang hat eine EIGENE Abfrage, weil der Verlauf nur 200 Zeilen
liefert: Eine Ansage von vorletzter Woche waere sonst genau dann weg,
wenn sie am laengsten oben stehen sollte. Wird die Nachricht
zurueckgenommen, faellt sie ab UND gibt den Platz frei.

DREI DINGE, DIE ERST DAS HINSEHEN GEZEIGT HAT:

  Der frisch angelegte Kanal hatte zwei Leute statt vier. Die
  Personenauswahl zeichnete nach der Rollenfolge aus bereiche.js -- wer
  dort nicht steht, wurde NICHT GEZEICHNET. Team Dogi steht dort nicht
  und darf es auch nicht (der Rollenname gehoert in keine Datei, die
  jeder herunterlaedt). Folge: DogFather konnte ueber die Auswahl
  niemandem aus seinem Team schreiben. Der Server schickt die
  Ueberschrift jetzt mit; der Browser braucht dafuer keinen
  Rollennamen. Der Kommentar, der genau davor warnte, stand die ganze
  Zeit darueber.

  Dieser Fehler war nebenbei ein Netz: Was nicht gezeichnet wird, kann
  auch nicht falsch gezeichnet werden. Deshalb ist jetzt gemessen, dass
  ein Manager und Spicy Media Team Dogi gar nicht erst geschickt
  bekommen -- und dabei fiel auf, dass Spicy Media in der EIGENEN
  Auswahl stand: Sobald es jemanden zu verbergen gibt, schreibt
  ohneVerborgene() aus "sieht alles" eine echte Liste, und darin steckt
  man selbst.

  Am Fuss jeder Nachricht stehen jetzt vier Handgriffe statt drei. Bei
  390 px -- der haeufigsten Handybreite -- stand "kopieren" 18 px ueber
  der Blase, bei 320 px 75. Behoben mit `flex-wrap: wrap` und nicht mit
  einer Schwelle: Eine Regel, die misst, bleibt beim fuenften Handgriff
  richtig; eine Zahl nicht. pruef-chat-optik misst es ab jetzt.

NACHGEZOGEN, was pruef-css-klassen an MEINEM letzten Commit fand:
teamlage.html lud kopf.js ohne wahl.js (der Sicht-Umschalter sah aus
wie aus einem anderen Programm -- kaputt war nichts, und genau deshalb
faellt es niemandem auf), und .tampel__tag stand auf 11,2 px. Beides
war schon gepusht, weil ich die Pruefung nicht laufen liess.

pruef-chat-kanaele 73 (neu) · pruef-chat 48 · pruef-chat-ausbau 64 ·
pruef-chat-anhaenge 60 · pruef-chat-optik 24 -> 26 · pruef-css-klassen
gruen · pruef-rollen 274 · pruef-modi-verborgen 78 · pruef-team-ampel 28
· pruef-start-ansicht gruen · pruef-modi-wortleck 5 ·
pruef-zwischenspeicher 21.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 20:18:04 +02:00

555 lines
29 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 { portMussFreiSein } = await import("./helfer-port.mjs");
await portMussFreiSein(4359, "die Kanalpruefung");
process.env.PORT = "4359";
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");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = "http://127.0.0.1:4359";
/* ---- 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: 4359, 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");
/* =======================================================================
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);