/* ===================================================================== Auskunft auf Knopfdruck — Art. 15 DSGVO (15.09.2026) --------------------------------------------------------------------- DIE EINE FRAGE, DIE ZÄHLT: IST SIE VOLLSTÄNDIG? Eine Auskunft, die eine Tabelle vergisst, ist schlimmer als keine — sie ist eine falsche Aussage über die Daten eines Menschen. Deshalb prüft diese Datei nicht, ob *etwas* zurückkommt, sondern ob **jede** Stelle gefunden wird, an der etwas liegt: 1. Jede Spalte mit Fremdschlüssel auf `personen` ist erfasst – abgeleitet aus der Datenbank, nicht gegen eine Zahl. 2. Jede Spalte, die auf `_id` endet und KEINEN Fremdschlüssel hat, ist eingeordnet: entweder „ist eine Person" oder „ist keine". Kommt eine fünfte dazu, wird das hier rot — und genau daran stirbt sonst jede gepflegte Liste. 3. Daten werden wirklich gefunden: In sechs Tabellen wird etwas angelegt, und alle sechs müssen in der Auskunft auftauchen. --------------------------------------------------------------------- UND ZWEI, DIE NIEMALS DRINSTEHEN DÜRFEN Zugangsgeheimnisse (Hash, Salt, Kennung) — Risiko ohne Nutzen Andere Menschen — deren Daten gehören ihnen Aufrufen mit: node server/pruef-auskunft.mjs ===================================================================== */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { request as httpAnfrage } from "node:http"; import { portMussFreiSein } from "./helfer-port.mjs"; const PORT = await portMussFreiSein(4412, "pruef-auskunft"); const ordner = mkdtempSync(join(tmpdir(), "ws-ausk-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = String(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 }); }; import { notbremse } from "./helfer-notbremse.mjs"; await import("./index.js"); notbremse(180_000, "pruef-auskunft"); await new Promise((r) => setTimeout(r, 700)); process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); let fehler = 0, geprueft = 0; const melde = (t) => console.log(t); const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const CREW = "crew.dogfather-universe.com"; const HAUS = "workspace.dogfather-universe.com"; const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const modul = await import("./workspace-auskunft.js"); const oeffne = () => new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); let idDogi, idRieke, idGast; { const d = oeffne(); const schluessel = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", schluessel, jetzt); const anlegen = (name, rolle, code) => { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + " VALUES (?,?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt); return d.prepare("SELECT last_insert_rowid() AS id").get().id; }; idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); idRieke = anlegen("Rieke", "hand", "CODE-HAND-0001"); idGast = anlegen("Kessi", "gast", "CODE-GAST-0001"); /* SPUREN IN SECHS TABELLEN -- eine Auskunft ueber einen Menschen ohne Daten beweist nichts. Bewusst verteilt: eigener Eintrag, fremder Eintrag mit mir darin, Chat, Protokoll (ohne Fremdschluessel) und treff_entfernt.autor_id (ebenfalls ohne). */ d.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von)" + " VALUES ('treff','hallo','KESSI-EINTRAG','Hallo zusammen',?,?,?)") .run(jetzt.slice(0, 10), jetzt, idGast); const raum = (() => { d.prepare("INSERT INTO chat_raeume (art, name, erstellt, erstellt_von) VALUES ('gruppe','Testraum',?,?)") .run(jetzt, idDogi); return d.prepare("SELECT last_insert_rowid() AS id").get().id; })(); d.prepare("INSERT INTO chat_teilnehmer (raum_id, person_id, seit) VALUES (?,?,?)").run(raum, idGast, jetzt); d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)") .run(raum, idGast, "KESSI-NACHRICHT", jetzt); d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)") .run(raum, idDogi, "FREMDE-NACHRICHT", jetzt); d.prepare("INSERT INTO protokoll (zeitpunkt, person_id, rolle, aktion, detail, ip)" + " VALUES (?,?,?,?,?,?)").run(jetzt, idGast, "gast", "anmeldung", "KESSI-PROTOKOLL", "203.0.113.5"); d.prepare("INSERT INTO treff_massnahmen (person_id, art, grund, von, zeitpunkt)" + " VALUES (?,?,?,?,?)").run(idGast, "hinweis", "KESSI-MASSNAHME", idDogi, jetzt); d.close(); } function roh(pfad, host, kopf = {}) { return new Promise((fertig, schief) => { const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: "GET", headers: { Host: host, ...kopf } }, (antwort) => { let text = ""; antwort.on("data", (s) => { text += s; }); antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers })); }); a.on("error", schief); a.end(); }); } function post(pfad, host, kopf, koerper) { return new Promise((fertig, schief) => { const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: "POST", headers: { Host: host, "Content-Type": "application/json", ...kopf } }, (antwort) => { let text = ""; antwort.on("data", (s) => { text += s; }); antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers })); }); a.on("error", schief); a.write(JSON.stringify(koerper)); a.end(); }); } const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } }; async function anmelden(rolle, code, host) { /* Die Community bestaetigt beim ersten Mal ihr Alter (seit 15.09.2026). Nur fuer 'gast' -- fuer alle anderen muss der Server das Feld gar nicht erst sehen. */ const r = await post("/workspace/api/anmelden", host, {}, rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code }); return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); } const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS); const kRieke = await anmelden("hand", "CODE-HAND-0001", CREW); const kGast = await anmelden("gast", "CODE-GAST-0001", CREW); ok(!!kDogi && !!kRieke && !!kGast, "DogFather, rechte Hand und ein Community-Mitglied sind angemeldet"); /* ======================================================================= 1. DIE QUELLEN SIND ABGELEITET — UND VOLLSTÄNDIG EINGEORDNET ======================================================================= */ melde(""); melde("=== 1. Vollständigkeit der Quellen ==="); { const d = oeffne(); const spalten = modul.personenSpalten(d); ok(spalten.length > 30, `${spalten.length} Spalten im Haus zeigen auf einen Menschen`); ok(spalten.some((s) => s.quelle === "fremdschluessel"), "die meisten über einen Fremdschlüssel"); ok(spalten.some((s) => s.quelle === "nachgetragen"), "und zwei sind nachgetragen"); /* DIE PRUEFUNG, DIE DIE LISTE AM LEBEN HAELT: Jede Spalte auf `_id` ohne Fremdschluessel muss eingeordnet sein -- als Person oder als keine. Baut jemand eine fuenfte, wird das hier rot, statt dass sie still aus der Auskunft faellt. */ const tabellen = d.prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all(); const undeklariert = []; for (const { name } of tabellen) { const fk = new Set(d.prepare(`PRAGMA foreign_key_list(${name})`).all().map((f) => f.from)); for (const c of d.prepare(`PRAGMA table_info(${name})`).all()) { if (!/_id$/i.test(c.name) || !/INT/i.test(c.type)) continue; if (fk.has(c.name)) continue; undeklariert.push(`${name}.${c.name}`); } } ok(undeklariert.length > 0, `${undeklariert.length} Spalten auf _id ohne Fremdschlüssel`); const alsPerson = new Set(modul.ZUSATZ.map((z) => `${z.tabelle}.${z.spalte}`)); const alsNicht = new Set(modul.KEINE_PERSON); const uneingeordnet = undeklariert.filter((s) => !alsPerson.has(s) && !alsNicht.has(s)); ok(uneingeordnet.length === 0, uneingeordnet.length ? `NICHT EINGEORDNET: ${uneingeordnet.join(", ")}` : `alle ${undeklariert.length} sind eingeordnet (${alsPerson.size} Person, ${alsNicht.size} nicht)`); /* Und keine Karteileiche: Was eingeordnet ist, muss es auch geben. */ const erfunden = [...alsPerson, ...alsNicht].filter((s) => !undeklariert.includes(s)); ok(erfunden.length === 0, erfunden.length ? `EINGEORDNET, GIBT ES ABER NICHT: ${erfunden.join(", ")}` : "und keine Karteileiche dabei"); ok(modul.ZUSATZ.every((z) => z.warum && z.warum.length > 20), "jede nachgetragene Spalte sagt, warum sie keinen Fremdschlüssel hat"); d.close(); } /* ======================================================================= 2. DIE DATEN WERDEN WIRKLICH GEFUNDEN ======================================================================= */ melde(""); melde("=== 2. Was über Kessi gespeichert ist ==="); { const a = await roh("/workspace/api/auskunft/mich", CREW, { cookie: kGast }); ok(a.code === 200, `ein Community-Mitglied bekommt seine Auskunft (${a.code})`); const d = json(a); ok(d.person?.name === "Kessi", `die eigene Zeile steht drin (${d.person?.name})`); ok(d.durchsucht > 30, `${d.durchsucht} Stellen durchsucht`); ok(d.gefunden >= 5, `an ${d.gefunden} davon liegt etwas über sie`); const namen = (d.tabellen || []).map((t) => t.tabelle); for (const muss of ["eintraege", "chat_nachrichten", "chat_teilnehmer", "protokoll", "treff_massnahmen"]) { ok(namen.includes(muss), `${muss} ist dabei`); } /* protokoll und treff_entfernt.autor_id haben KEINEN Fremdschluessel. Dass protokoll trotzdem auftaucht, beweist, dass die nachgetragene Liste wirkt -- ohne sie fiele es lautlos heraus. */ ok(namen.includes("protokoll"), "auch das Protokoll – und das hat gar keinen Fremdschlüssel"); const roher = a.text; ok(/KESSI-EINTRAG/.test(roher) && /KESSI-NACHRICHT/.test(roher) && /KESSI-MASSNAHME/.test(roher), "die Inhalte stehen wirklich drin, nicht nur die Tabellennamen"); /* GEGENPROBE: Was NICHT ihres ist, ist auch nicht drin. */ ok(!/FREMDE-NACHRICHT/.test(roher), "die Nachricht eines anderen aus demselben Raum steht NICHT drin"); ok(d.nicht_lesbar?.length === 0, "und jede Stelle ließ sich lesen"); } /* ======================================================================= 3. ZWEI DINGE, DIE NIEMALS DRINSTEHEN ======================================================================= */ melde(""); melde("=== 3. Was nicht drinsteht ==="); { const a = await roh("/workspace/api/auskunft/mich", CREW, { cookie: kGast }); const roher = a.text; const d = json(a); /* 1. Zugangsgeheimnisse. */ ok(/Zugangsgeheimnis/.test(roher), "die Geheimnisfelder sind als solche markiert"); const dd = oeffne(); const echt = dd.prepare("SELECT code_hash, code_salt, code_kennung FROM personen WHERE id = ?").get(idGast); dd.close(); ok(!roher.includes(echt.code_hash), "der echte Hash steht NICHT darin"); ok(!roher.includes(echt.code_salt), "das Salt auch nicht"); ok(!roher.includes(echt.code_kennung), "und die Kennung ebenso wenig"); /* 2. Andere Menschen. Die Maßnahme wurde von DogFather gesetzt -- seine Nummer ist sein Datum, nicht ihres. */ const massnahme = (d.tabellen || []).find((t) => t.tabelle === "treff_massnahmen"); const zeile = massnahme?.zeilen?.[0]; ok(zeile && zeile.von === "(eine andere Person)", `wer die Maßnahme gesetzt hat, steht als „eine andere Person“ da (${zeile?.von})`); /* GEGENPROBE -- ohne sie beweist die Zeile darueber nichts: IHRE eigene Nummer bleibt stehen, sonst waere die Auskunft unbrauchbar. */ ok(zeile && Number(zeile.person_id) === Number(idGast), `ihre eigene Nummer steht unverändert da (${zeile?.person_id})`); ok(!new RegExp(`"von":\\s*${idDogi}\\b`).test(roher), "und nirgends im Text steht die Nummer des anderen in einer Personenspalte"); } /* ======================================================================= 4. WER WEN FRAGEN DARF ======================================================================= */ melde(""); melde("=== 4. Wer wen fragen darf ==="); { const eigene = await roh("/workspace/api/auskunft/mich", CREW, { cookie: kRieke }); ok(eigene.code === 200, `die rechte Hand bekommt ihre eigene (${eigene.code})`); ok(json(eigene).person?.name === "Rieke", "und zwar ihre, nicht die eines anderen"); const fremd = await roh(`/workspace/api/auskunft/${idGast}`, CREW, { cookie: kRieke }); ok(fremd.code === 404, `die eines anderen bekommt sie nicht (${fremd.code})`); ok(!/KESSI-NACHRICHT/.test(fremd.text), "und kein Wort daraus"); const gastFremd = await roh(`/workspace/api/auskunft/${idRieke}`, CREW, { cookie: kGast }); ok(gastFremd.code === 404, `ein Community-Mitglied erst recht nicht (${gastFremd.code})`); /* GEGENPROBE: DogFather kann es -- er ist der Verantwortliche. Ohne diese Zeile waere „404" oben auch dann gruen, wenn es den Weg gar nicht gibt. */ const chef = await roh(`/workspace/api/auskunft/${idGast}`, HAUS, { cookie: kDogi }); ok(chef.code === 200, `DogFather schon (${chef.code})`); ok(json(chef).person?.name === "Kessi", "und bekommt die richtige Person"); const ohne = await roh("/workspace/api/auskunft/mich", HAUS, {}); ok(ohne.code === 401, `ohne Anmeldung gar nichts (${ohne.code})`); const erfunden = await roh("/workspace/api/auskunft/999999", HAUS, { cookie: kDogi }); ok(erfunden.code === 404, `eine erfundene Nummer ergibt 404 (${erfunden.code})`); } /* ======================================================================= 5. JEDE AUSKUNFT HINTERLÄSST EINE SPUR ======================================================================= */ melde(""); melde("=== 5. Nachvollziehbar ==="); { const d = oeffne(); const selbst = d.prepare("SELECT COUNT(*) AS n FROM protokoll WHERE aktion = 'auskunft_selbst'").get().n; const fremd = d.prepare("SELECT detail FROM protokoll WHERE aktion = 'auskunft_fremd'").all(); d.close(); ok(selbst >= 3, `${selbst} eigene Auskünfte im Protokoll`); ok(fremd.length >= 1, `${fremd.length} Auskunft über einen anderen Menschen`); ok(/Kessi/.test(fremd[0]?.detail || ""), `und dort steht, über wen (${fremd[0]?.detail})`); } /* ======================================================================= 6. DER KNOPF STEHT AUF DEN SEITEN, DIE DEM MENSCHEN GEHÖREN ======================================================================= */ melde(""); melde("=== 6. Der Knopf ==="); { const { readFileSync } = await import("node:fs"); for (const seite of ["steckbrief.html", "treff-regeln.html"]) { const s = readFileSync(new URL("../workspace/" + seite, import.meta.url), "utf8"); ok(/id="auskunft-knopf"/.test(s), `${seite} hat den Knopf`); ok(/assets\/js\/auskunft\.js/.test(s), `${seite} lädt das Skript dazu`); } /* WARUM AUSGERECHNET DIESE ZWEI: Die Community hat keinen Steckbrief. Ohne die zweite Stelle haette die Gruppe mit den wenigsten Rechten auch dieses nicht -- und sie ist die groesste. */ const rechte = await import("./rechte.js"); const tafel = rechte.tafelJetzt(); ok(!(tafel["/workspace/steckbrief.html"] || []).includes("gast"), "die Community hat keinen Steckbrief"); ok((tafel["/workspace/treff-regeln.html"] || []).includes("gast"), "aber die Treff-Regeln – deshalb steht er auch dort"); } /* ------------------------------------------------------------------- */ melde(""); melde(`${geprueft} Pruefungen, ${fehler} Fehler`); melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } process.exit(fehler ? 1 : 0);