/* RÜCKMELDUNG IN BEIDE RICHTUNGEN (10.09.2026) Kapitel 5 und 6 des Pflichtenhefts: "Nicht nur ich soll meine Modis bewerten oder ihnen Feedback geben können. Auch die Modis sollen mir Feedback geben können. Sie sollen mir beispielsweise sagen können: Was könnte ich verbessern?" "Es soll nicht nur dazu dienen, Leistungen zu kontrollieren. Es soll vor allem dabei helfen, als Team besser zu werden." --------------------------------------------------------------------- DIE PRÜFUNG, DERENTWEGEN ES DIESE DATEI GIBT Wer beim Schreiben „nur an DogFather" wählt, verlässt sich darauf. Diese Zusage hat genau eine Stelle, an der sie gilt -- `sichtbarEintrag()` --, und genau zwei Arten, still zu brechen: Sie könnte NICHT WEIT GENUG gehen: Die Liste ist gefiltert, aber über die Nummer der Zeile (Ändern, Löschen) kommt man trotzdem heran. Deshalb wird hier auch von hinten geklopft. Sie könnte ZU WEIT gehen: `nur_leitung` ist eine neue Spalte, und jede Zeile, die es vorher gab, hat dort NULL. In SQL ist `NULL = 0` nicht falsch, sondern UNBEKANNT -- ohne COALESCE wäre der gesamte alte Bestand von einer Minute auf die andere unsichtbar gewesen. Ein leeres Brett sieht nicht nach Fehler aus; das hätte niemand gemeldet. Beide Fälle stehen unten, jeder mit seiner Gegenprobe. ===================================================================== */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const ordner = mkdtempSync(join(tmpdir(), "ws-rueck-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); const { eigenerPort } = await import("./helfer-port.mjs"); const PORT = await eigenerPort(import.meta, "die Rueckmeldungspruefung"); process.env.PORT = `${PORT}`; process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; const express = (await import("express")).default; const ec = express.response.cookie; express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; await import("./index.js"); await new Promise((r) => setTimeout(r, 1000)); const BASIS = `http://127.0.0.1:${PORT}`; /* Netz und Notbremse -- index.js faengt sonst jeden Absturz ab, und der Lauf sieht von aussen aus wie "laeuft noch". */ for (const art of ["uncaughtException", "unhandledRejection"]) { process.on(art, (f) => { console.error(""); console.error("ABBRUCH:", f?.stack || f); process.exit(1); }); } setTimeout(() => { console.error(""); console.error("ABBRUCH: 120 s vorbei."); process.exit(1); }, 120000).unref?.(); let fehler = 0, geprueft = 0; const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; 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"); anlegen("Vanessa", "hand", "CODE-HAND-0001"); const idAyla = anlegen("Ayla", "modi", "CODE-TEAM-0001"); anlegen("Ben", "modi", "CODE-TEAM-0002"); anlegen("Cigdem", "manager", "CODE-MANA-0001"); /* Team-Rollen kommen nur ueber die Crew-Wand herein, Agenturrollen nur ueber die Agenturwand -- die Regel vom 10.09.2026. `fetch` kann den Host-Kopf nicht setzen (undici setzt ihn aus der Adresse), deshalb node:http. */ 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(); /* MIT EINER BESTIMMTEN ADRESSE FRAGEN -- UND WARUM NICHT MIT `fetch` (21.09.2026). Welches Haus jemand sieht, entscheidet der Host der EINZELNEN Anfrage. Eine Anmeldung ueber die Crew-Wand genuegt nicht, wenn die naechste Abfrage an 127.0.0.1 geht. `fetch` kann das nicht: `Host` ist ein verbotener Kopfzeilenname und wird STILLSCHWEIGEND verworfen -- kein Fehler, keine Warnung, die Anfrage geht einfach an die falsche Adresse. Genau deshalb benutzt auch `anmelden()` weiter unten schon `http.request` statt `fetch`. Eine halbe Stunde Suche waere das gewesen, haette die Pruefung nicht Kachel fuer Kachel gemeldet, was sie findet. */ const holenVon = (keks, weg, wand) => new Promise((fertig, schief) => { const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: weg, method: "GET", headers: { Cookie: keks, Host: wand }, }, (antwort) => { let text = ""; antwort.on("data", (s2) => { text += s2; }); antwort.on("end", () => { try { fertig(JSON.parse(text || "{}")); } catch { fertig({}); } }); }); a.on("error", schief); a.end(); }); const dogi = await anmelden("admin", "CODE-DOGI-0001"); const hand = await anmelden("hand", "CODE-HAND-0001"); const ayla = await anmelden("modi", "CODE-TEAM-0001"); const ben = await anmelden("modi", "CODE-TEAM-0002"); const cigdem = await anmelden("manager", "CODE-MANA-0001", "workspace.dogfather-universe.com"); ok([dogi, hand, ayla, ben, cigdem].every((x) => x.ok && x.keks), "alle fuenf sind angemeldet"); const WEG = "/workspace/api/bereich/rueckmeldung"; const titel = async (keks) => ((await holen(keks, WEG)).eintraege || []).map((e) => e.titel); /* ======================================================================= 1. Es gibt den Bereich, und er hat die Fragen aus dem Pflichtenheft ======================================================================= */ console.log(""); console.log("=== Der Bereich ==="); { const a = await holen(dogi.keks, WEG); ok(a.einstellung?.name === "Rückmeldung", `DogFather kann ihn oeffnen (${a.einstellung?.name})`); const arten = Object.keys(a.einstellung?.arten || {}); ok(arten.length === 9, `neun Fragen (${arten.join(", ")})`); ok(a.einstellung?.vertraulich === true, "und er kennt die vertrauliche Zeile"); const b = await holen(ayla.keks, WEG); ok(b.einstellung?.name === "Rückmeldung", "das Team kann ihn auch oeffnen"); /* GEGENPROBE: Die Agentur hat hier nichts zu suchen -- 404 und nicht 403, sonst waere allein die Antwort schon eine Auskunft. */ const c = await rufe(cigdem.keks, WEG); ok(c.status === 404, `ein Manager kommt nicht hinein (${c.status})`); } /* ======================================================================= 2. Beide Richtungen ======================================================================= */ console.log(""); console.log("=== Wer schreibt wem ==="); let offenId = 0, geheimId = 0; { const a = await rufe(ayla.keks, WEG, { art: "gut", titel: "Die Absprachen vor dem Live helfen sehr", datum: "2026-09-10", dringlichkeit: "niedrig", }, "POST"); offenId = (await a.json())?.id; ok(a.status === 201 && offenId > 0, `ein Teammitglied schreibt fuers Team (${a.status})`); const b = await rufe(ayla.keks, WEG, { art: "dogfather", titel: "Du unterbrichst uns oft mitten im Satz", datum: "2026-09-10", dringlichkeit: "mittel", nur_leitung: 1, }, "POST"); geheimId = (await b.json())?.id; ok(b.status === 201 && geheimId > 0, `und eine nur an DogFather (${b.status})`); const c = await rufe(dogi.keks, WEG, { art: "behalten", titel: "Die Schichtübergabe so lassen", datum: "2026-09-10", dringlichkeit: "niedrig", }, "POST"); ok(c.status === 201, `DogFather schreibt in dieselbe Sammlung (${c.status})`); /* Die Spalte muss wirklich gesetzt sein -- nicht nur die Antwort stimmen. Ohne diese Zeile waere die Pruefung gruen, wenn das Feld stillschweigend verworfen wuerde. */ const zeile = d.prepare("SELECT nur_leitung FROM eintraege WHERE id = ?").get(geheimId); ok(zeile?.nur_leitung === 1, `die Zeile steht als vertraulich in der Datenbank (${zeile?.nur_leitung})`); const offen = d.prepare("SELECT nur_leitung FROM eintraege WHERE id = ?").get(offenId); ok(offen?.nur_leitung === 0, `und die andere als offen (${offen?.nur_leitung})`); } /* ======================================================================= 3. DIE ZUSAGE: „nur DogFather" heisst nur DogFather ======================================================================= */ console.log(""); console.log("=== Wer sieht was ==="); { const beiDogi = await titel(dogi.keks); ok(beiDogi.some((t) => t.startsWith("Du unterbrichst")), `DogFather sieht die vertrauliche Zeile (${beiDogi.length} Eintraege)`); const beiAyla = await titel(ayla.keks); ok(beiAyla.some((t) => t.startsWith("Du unterbrichst")), "die Schreiberin sieht ihre eigene"); /* DIE EIGENTLICHE PRUEFUNG. Die rechte Hand sieht sonst ueberall dasselbe wie DogFather -- hier nicht. */ const beiHand = await titel(hand.keks); ok(!beiHand.some((t) => t.startsWith("Du unterbrichst")), `die rechte Hand sieht sie NICHT (${beiHand.join(" | ")})`); const beiBen = await titel(ben.keks); ok(!beiBen.some((t) => t.startsWith("Du unterbrichst")), "ein anderes Teammitglied auch nicht"); /* GEGENPROBE: Sonst koennte "sieht sie nicht" auch heissen "sieht ueberhaupt nichts". Die OFFENE Zeile muss bei beiden ankommen. */ ok(beiHand.some((t) => t.startsWith("Die Absprachen")), "die offene Zeile sehen beide sehr wohl"); ok(beiBen.some((t) => t.startsWith("Die Absprachen")), "auch das andere Teammitglied"); } /* ======================================================================= 4. Und von hinten? Die Nummer ist kein Schluessel ======================================================================= */ console.log(""); console.log("=== Ueber die Nummer ==="); { const a = await rufe(hand.keks, `${WEG}/${geheimId}`, { titel: "umgeschrieben" }, "PATCH"); ok(a.status === 404, `die rechte Hand kann sie nicht aendern (${a.status})`); const b = await rufe(hand.keks, `${WEG}/${geheimId}`, undefined, "DELETE"); ok(b.status === 404, `und nicht loeschen (${b.status})`); const heil = d.prepare("SELECT titel FROM eintraege WHERE id = ?").get(geheimId); ok(heil?.titel?.startsWith("Du unterbrichst"), "die Zeile steht unveraendert da"); /* GEGENPROBE: An der OFFENEN Zeile darf sie arbeiten -- sonst hiesse die 404 oben nur "diese Route geht fuer sie nie". */ const c = await rufe(hand.keks, `${WEG}/${offenId}`, { status: "erledigt" }, "PATCH"); ok(c.status === 200, `an der offenen Zeile darf sie arbeiten (${c.status})`); } /* ======================================================================= 5. Der alte Bestand (NULL) bleibt sichtbar ======================================================================= */ console.log(""); console.log("=== Was vor der Spalte da war ==="); { d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, erstellt, erstellt_von, nur_leitung) VALUES ('rueckmeldung','idee','Aus der Zeit vor der Spalte',?,?,?,NULL)`) .run("2026-09-01", jetzt, idAyla); const beiHand = await titel(hand.keks); ok(beiHand.some((t) => t === "Aus der Zeit vor der Spalte"), "eine Zeile mit NULL gilt als offen und bleibt sichtbar"); /* Die Gegenprobe zur Gegenprobe: Genau diese Zeile MIT 1 waere weg. */ d.prepare("UPDATE eintraege SET nur_leitung = 1 WHERE titel = 'Aus der Zeit vor der Spalte'").run(); const nachher = await titel(hand.keks); ok(!nachher.some((t) => t === "Aus der Zeit vor der Spalte"), "dieselbe Zeile auf 1 gesetzt ist fuer sie weg -- die Regel greift wirklich"); d.prepare("UPDATE eintraege SET nur_leitung = NULL WHERE titel = 'Aus der Zeit vor der Spalte'").run(); } /* ======================================================================= 6. In anderen Bereichen gibt es das Feld nicht ======================================================================= */ console.log(""); console.log("=== Kein Versteck in anderen Bereichen ==="); { const a = await rufe(ayla.keks, "/workspace/api/bereich/ideen", { art: "content", titel: "Eine ganz normale Idee", datum: "2026-09-10", nur_leitung: 1, }, "POST"); const id = (await a.json())?.id; ok(a.status === 201, `eine Idee laesst sich anlegen (${a.status})`); const zeile = d.prepare("SELECT nur_leitung FROM eintraege WHERE id = ?").get(id); ok(!zeile?.nur_leitung, `das Feld wird dort stillschweigend verworfen (${zeile?.nur_leitung})`); const beiHand = await holen(hand.keks, "/workspace/api/bereich/ideen"); ok((beiHand.eintraege || []).some((e) => e.id === id), "und die Idee steht fuer alle da -- niemand kann sich dort verstecken"); } /* ======================================================================= 7. Die Kachel steht bei allen dreien -- mit demselben Namen ======================================================================= */ console.log(""); console.log("=== Die Kachel ==="); { /* GEFRAGT WIRD IM EIGENEN HAUS (21.09.2026). Bis heute fragte dieser Abschnitt ueber 127.0.0.1 -- also ueber eine Pruefadresse, auf der die Agentursicht gilt. Solange DogFather dort auch die Kacheln von Team Dogi bekam, fiel das nicht auf. Seit dem 21.09. bekommt er sie dort nicht mehr: Filipe wollte "keine mischung mehr von kacheln", Team Dogi steht nur noch auf seiner eigenen Adresse. Die Pruefung war damit rot, ohne dass die Kachel fehlt -- sie hat nur am falschen Ort gesucht. Gefragt wird deshalb mit dem Host der Crew-Wand, also genau dort, wo diese Kachel zuhause ist. */ const CREW_WAND = "crew.dogfather-universe.com"; const kacheln = async (keks) => { const i = await holenVon(keks, "/workspace/api/ich", CREW_WAND); return [...(i.bereiche || []), ...(i.bereiche_zusatz || [])]; }; for (const [wer, keks] of [["DogFather", dogi.keks], ["die rechte Hand", hand.keks], ["das Team", ayla.keks]]) { const k = (await kacheln(keks)).filter((x) => x.ziel === "bereich.html?b=rueckmeldung"); ok(k.length === 1 && k[0].name === "Rückmeldung", `${wer}: genau eine Kachel, und sie heisst gleich (${k.map((x) => x.name).join("/") || "keine"})`); } /* Der Farbton muss neu sein -- zwei Kacheln in derselben Farbe sind ein Fehler, den pruef-start-ansicht sonst erst am Bildschirm findet. */ const alle = await kacheln(dogi.keks); const toene = alle.map((x) => x.ton).filter((t) => t !== undefined); ok(new Set(toene).size === toene.length, `kein Farbton doppelt (${toene.length} Kacheln vom Server)`); } d.close(); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } console.log(""); console.log(`${fehler === 0 ? "BESTANDEN" : "FEHLGESCHLAGEN"} -- ${geprueft} geprueft, ${fehler} Fehler`); process.exit(fehler ? 1 : 0);