/* ===================================================================== Das Löschkonzept (15.09.2026) Stufe 8 aus dem Plan: „Löschkonzept je Datenart · IP-Adressen im Protokoll nach 30 Tagen weg." --------------------------------------------------------------------- BEI ETWAS, DAS LÖSCHT, IST DIE WICHTIGERE HÄLFTE: WAS ES NICHT LÖSCHT Eine Aufräumfunktion, die zu viel mitnimmt, fällt erst auf, wenn jemand etwas sucht, das es nicht mehr gibt — und dann ist es weg. Deshalb steht zu jeder Löschung hier eine Gegenprobe daneben: IP nach 30 Tagen weg → und die von gestern bleibt IP weg → und die ZEILE bleibt (nur das Feld) abgelaufene Sitzung weg → und die gültige bleibt abgelaufene Sitzung weg → und ich bin danach noch angemeldet Die letzte ist die unbequemste und die wichtigste: Ein Aufräumlauf, der die eigene Anmeldung mitnimmt, wirft im Betrieb alle hinaus. Aufrufen mit: node server/pruef-aufbewahrung.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(4410, "pruef-aufbewahrung"); const ordner = mkdtempSync(join(tmpdir(), "ws-aufb-")); 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-aufbewahrung"); 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-aufbewahrung.js"); const oeffne = () => new DatabaseSync(process.env.WORKSPACE_DB); const vorTagen = (n) => new Date(Date.now() - n * 86400000).toISOString(); const inTagen = (n) => new Date(Date.now() + n * 86400000).toISOString(); { const d = oeffne(); const jetzt = new Date().toISOString(); 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; }; anlegen("Filipe", "admin", "CODE-DOGI-0001"); anlegen("Rieke", "hand", "CODE-HAND-0001"); d.close(); } function roh(pfad, host, kopf = {}, koerper = null, methode = null) { if (methode === "DELETE") koerper = null; return new Promise((fertig, schief) => { const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: methode || (koerper ? "POST" : "GET"), headers: { Host: host, ...(koerper ? { "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); if (koerper) a.write(JSON.stringify(koerper)); a.end(); }); } const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } }; async function anmelden(rolle, code, host) { const r = await roh("/workspace/api/anmelden", host, {}, { rolle, code }); return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); } const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS); const kHand = await anmelden("hand", "CODE-HAND-0001", CREW); ok(!!kDogi && !!kHand, "DogFather und die rechte Hand sind angemeldet"); /* ======================================================================= 1. DIE TAFEL IST VOLLSTÄNDIG ======================================================================= */ melde(""); melde("=== 1. Die Tafel ==="); { const f = modul.FRISTEN; ok(f.length >= 5, `${f.length} Datenarten im Löschkonzept`); ok(new Set(f.map((x) => x.schluessel)).size === f.length, "kein Schlüssel doppelt"); const unvollstaendig = f.filter((x) => !x.was || !x.frist || !x.grundlage || !x.wirkung || !x.art); ok(unvollstaendig.length === 0, unvollstaendig.length ? `UNVOLLSTÄNDIG: ${unvollstaendig.map((x) => x.schluessel).join(", ")}` : "jede Datenart nennt Frist, Rechtsgrundlage und Wirkung"); const arten = new Set(f.map((x) => x.art)); ok([...arten].every((a) => ["raeumen", "fremd", "bleibt"].includes(a)), `nur die drei bekannten Arten (${[...arten].join(", ")})`); /* Ein Loeschkonzept braucht beides: was geht und was bleibt. Stuende hier nur „raeumen", waere es eine Aufraeumliste, kein Konzept. */ ok(arten.has("raeumen") && arten.has("bleibt"), "es gibt sowohl Gelöschtes als auch begründet Aufgehobenes"); ok(f.every((x) => typeof x.offen === "function"), "jede Zeile kann sagen, wie viele Datensätze sie betrifft"); ok(f.filter((x) => x.art === "raeumen").every((x) => typeof x.raeumen === "function"), "und jede, die räumt, kann es auch"); ok(f.filter((x) => x.art !== "raeumen").every((x) => typeof x.raeumen !== "function"), "was nicht räumen soll, kann es auch nicht — „bleibt“ ist keine Absichtserklärung"); } /* ======================================================================= 2. IP-ADRESSEN NACH 30 TAGEN — UND DIE VON GESTERN BLEIBT ======================================================================= */ melde(""); melde("=== 2. IP-Adressen im Protokoll ==="); { const d = oeffne(); const rein = d.prepare( "INSERT INTO protokoll (zeitpunkt, person_id, rolle, aktion, detail, ip) VALUES (?,?,?,?,?,?)"); rein.run(vorTagen(modul.IP_TAGE + 10), null, null, "test_alt", "ALT", "203.0.113.7"); rein.run(vorTagen(modul.IP_TAGE - 1), null, null, "test_jung", "JUNG", "203.0.113.8"); rein.run(vorTagen(modul.IP_TAGE + 10), null, null, "test_ohne", "OHNE", null); d.close(); const vorher = json(await roh("/workspace/api/aufbewahrung", HAUS, { cookie: kDogi })); const zeile = (vorher.fristen || []).find((z) => z.schluessel === "protokoll_ip"); ok(zeile && zeile.anzahl >= 1, `${zeile?.anzahl} IP(s) warten auf das Löschen`); const lauf = await roh("/workspace/api/aufbewahrung/jetzt", HAUS, { cookie: kDogi }, {}); ok(lauf.code === 200, `der Lauf geht durch (${lauf.code})`); const getan = json(lauf).getan || []; ok(getan.some((g) => g.schluessel === "protokoll_ip" && g.anzahl >= 1), `und meldet, was er getan hat (${JSON.stringify(getan)})`); const d2 = oeffne(); const alt = d2.prepare("SELECT ip FROM protokoll WHERE aktion = 'test_alt'").get(); const jung = d2.prepare("SELECT ip FROM protokoll WHERE aktion = 'test_jung'").get(); const zeilen = d2.prepare("SELECT COUNT(*) AS n FROM protokoll WHERE aktion LIKE 'test_%'").get().n; d2.close(); ok(alt && alt.ip === null, "die IP von vor 40 Tagen ist weg"); /* GEGENPROBE 1 -- ohne sie beweist die Zeile darüber nichts: Eine Funktion, die ALLE IPs leert, bestünde sie genauso. */ ok(jung && jung.ip === "203.0.113.8", `die von vor ${modul.IP_TAGE - 1} Tagen steht noch da (${jung?.ip})`); /* GEGENPROBE 2: Geleert wird das FELD, nicht die Zeile. Das Protokoll ist die Sicherung gegen stille Änderungen -- ein Aufräumlauf, der Einträge entfernt, hebelt genau die aus. */ ok(zeilen === 3, `alle ${zeilen} Protokollzeilen stehen noch (nur das Feld wurde geleert)`); const nachher = json(await roh("/workspace/api/aufbewahrung", HAUS, { cookie: kDogi })); const zeile2 = (nachher.fristen || []).find((z) => z.schluessel === "protokoll_ip"); ok(zeile2 && zeile2.anzahl === 0, `danach wartet keine mehr (${zeile2?.anzahl})`); } /* ======================================================================= 3. ABGELAUFENE ANMELDUNGEN — UND MEINE EIGENE BLEIBT ======================================================================= */ melde(""); melde("=== 3. Abgelaufene Anmeldungen ==="); { const d = oeffne(); const vorher = d.prepare("SELECT COUNT(*) AS n FROM sitzungen").get().n; d.prepare("INSERT INTO sitzungen (token_hash, person_id, erstellt, gueltig_bis, ip, browser)" + " VALUES (?,?,?,?,?,?)") .run("test-abgelaufen", 1, vorTagen(3), vorTagen(1), "203.0.113.9", "Testbrowser"); d.prepare("INSERT INTO sitzungen (token_hash, person_id, erstellt, gueltig_bis, ip, browser)" + " VALUES (?,?,?,?,?,?)") .run("test-gueltig", 1, new Date().toISOString(), inTagen(1), "203.0.113.10", "Testbrowser"); d.close(); ok(vorher >= 2, `${vorher} echte Anmeldungen vorhanden (meine eigene ist dabei)`); const lauf = json(await roh("/workspace/api/aufbewahrung/jetzt", HAUS, { cookie: kDogi }, {})); ok((lauf.getan || []).some((g) => g.schluessel === "sitzungen_abgelaufen" && g.anzahl === 1), `genau eine abgelaufene Anmeldung geräumt (${JSON.stringify(lauf.getan)})`); const d2 = oeffne(); const weg = d2.prepare("SELECT 1 FROM sitzungen WHERE token_hash = 'test-abgelaufen'").get(); const da = d2.prepare("SELECT 1 FROM sitzungen WHERE token_hash = 'test-gueltig'").get(); const uebrig = d2.prepare("SELECT COUNT(*) AS n FROM sitzungen").get().n; d2.close(); ok(!weg, "die abgelaufene ist weg"); ok(!!da, "die gültige steht noch da"); ok(uebrig === vorher + 1, `und alle echten auch (${uebrig} statt ${vorher + 1})`); /* DIE UNBEQUEMSTE GEGENPROBE: Bin ich danach noch angemeldet? Ein Aufräumlauf, der die eigene Sitzung mitnimmt, wirft im Betrieb alle hinaus -- und zwar genau einmal täglich. */ const ich = await roh("/workspace/api/ich", HAUS, { cookie: kDogi }); ok(ich.code === 200, `ich bin nach dem Lauf noch angemeldet (${ich.code})`); const sie = await roh("/workspace/api/ich", CREW, { cookie: kHand }); ok(sie.code === 200, `die rechte Hand auch (${sie.code})`); } /* ======================================================================= 4. WER DIE TAFEL SEHEN DARF ======================================================================= */ melde(""); melde("=== 4. Wer sie sieht ==="); { const hand = await roh("/workspace/api/aufbewahrung", CREW, { cookie: kHand }); ok(hand.code === 404, `die rechte Hand bekommt 404 statt 403 (${hand.code})`); ok(!/Rechtsgrundlage|Art\. 6/.test(hand.text), "und kein Wort aus der Tafel"); const ohne = await roh("/workspace/api/aufbewahrung", HAUS, {}); ok(ohne.code === 401, `ohne Anmeldung 401 (${ohne.code})`); const laufFremd = await roh("/workspace/api/aufbewahrung/jetzt", CREW, { cookie: kHand }, {}); ok(laufFremd.code === 404, `und niemand sonst stößt den Lauf an (${laufFremd.code})`); /* GEGENPROBE: DogFather kann beides -- sonst wäre „404“ oben auch dann grün, wenn es den Weg gar nicht gibt. */ const chef = await roh("/workspace/api/aufbewahrung", HAUS, { cookie: kDogi }); ok(chef.code === 200, `DogFather schon (${chef.code})`); } /* ======================================================================= 5. DER LAUF HINTERLÄSST EINE SPUR ======================================================================= */ melde(""); melde("=== 5. Nachvollziehbar ==="); { const d = oeffne(); const spur = d.prepare( "SELECT COUNT(*) AS n FROM protokoll WHERE aktion LIKE 'aufbewahrung%'").get().n; d.close(); ok(spur >= 1, `${spur} Protokolleinträge über das Aufräumen selbst`); const tafel = json(await roh("/workspace/api/aufbewahrung", HAUS, { cookie: kDogi })); ok(tafel.letzte && tafel.letzte.zeitpunkt, `der letzte Lauf steht in der Tafel (${tafel.letzte?.zeitpunkt?.slice(0, 16)})`); ok(Array.isArray(tafel.letzte?.fehler) && tafel.letzte.fehler.length === 0, "und er lief ohne Fehler"); /* EIN ZWEITER LAUF DARF NICHTS MEHR FINDEN -- und muss das auch sagen dürfen. Eine Funktion, die jedes Mal „aufgeräumt" meldet, ohne dass etwas da war, wäre nicht zu unterscheiden von einer, die nichts tut. */ const zweiter = json(await roh("/workspace/api/aufbewahrung/jetzt", HAUS, { cookie: kDogi }, {})); ok((zweiter.getan || []).length === 0, `der zweite Lauf findet nichts mehr (${JSON.stringify(zweiter.getan)})`); } /* ======================================================================= 6. DRITTER AUSGANG: WAS, WENN EINE TABELLE FEHLT? ======================================================================= */ melde(""); melde("=== 6. Konnte nicht nachsehen ==="); { /* Eine Zählung, die scheitert, muss `null` liefern -- nicht 0. „Null Datensätze betroffen" und „konnte nicht nachsehen" sind zwei verschiedene Auskünfte, und die zweite darf nicht wie die erste aussehen. */ const d = oeffne(); const erfunden = { offen: (dd) => { try { return dd.prepare("SELECT COUNT(*) AS n FROM gibt_es_nicht").get()?.n ?? null; } catch { return null; } }, }; const wert = erfunden.offen(d); d.close(); ok(wert === null, `eine Zählung über eine fehlende Tabelle ergibt null, nicht 0 (${wert})`); const tafel = json(await roh("/workspace/api/aufbewahrung", HAUS, { cookie: kDogi })); const zahlen = (tafel.fristen || []).map((z) => z.anzahl); ok(zahlen.length === modul.FRISTEN.length && zahlen.every((z) => z !== null), `alle ${zahlen.length} Zeilen konnten wirklich zählen (${zahlen.join(", ")})`); } /* ======================================================================= 7. UND STEHT ES AUCH AUF DEM BILDSCHIRM? Ein Loeschkonzept, das nur in einer Schnittstelle antwortet, ist ein Dokument in einer Schublade. Geprueft wird deshalb die Seite selbst -- und der Knopf: Er muss eine Zahl wirklich fallen lassen. ======================================================================= */ melde(""); melde("=== 7. Auf der Seite ==="); { const { chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await chromium.launch(); const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const seite = await kontext.newPage(); const konsole = []; seite.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); }); seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); await seite.goto(`http://127.0.0.1:${PORT}/workspace/`, { waitUntil: "domcontentloaded" }); await seite.click('.rolle[data-rolle="admin"]'); await seite.fill("#code", "CODE-DOGI-0001"); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 20000 }); /* Etwas zu raeumen anlegen, DAMIT DER KNOPF ETWAS ZU TUN HAT. Ohne das waere „die Zahl faellt" trivial wahr: sie stand schon auf null. */ { const d = oeffne(); d.prepare( "INSERT INTO protokoll (zeitpunkt, person_id, rolle, aktion, detail, ip) VALUES (?,?,?,?,?,?)") .run(vorTagen(modul.IP_TAGE + 5), null, null, "test_bildschirm", "X", "203.0.113.11"); d.close(); } await seite.goto(`http://127.0.0.1:${PORT}/workspace/automation.html`, { waitUntil: "networkidle" }); await seite.waitForSelector(".aufb", { timeout: 8000 }).catch(() => {}); const stand = await seite.evaluate(() => { const zeilen = [...document.querySelectorAll(".aufb")]; return { anzahl: zeilen.length, arten: [...new Set(zeilen.map((z) => z.dataset.art))].sort(), schilder: [...new Set(zeilen.map((z) => z.querySelector(".aufb__art")?.textContent.trim()))], offen: zeilen.filter((z) => z.querySelector('.aufb__zahl[data-offen="ja"]')).length, text: document.getElementById("aufbewahrung")?.innerText || "", }; }); ok(stand.anzahl === modul.FRISTEN.length, `alle ${stand.anzahl} Datenarten stehen auf der Seite`); /* Die Sorte darf nicht NUR an der Farbe haengen -- wer Farben schlecht sieht, muss dasselbe lesen koennen. */ ok(stand.schilder.some((s) => /gelöscht/i.test(s || "")) && stand.schilder.some((s) => /bleibt/i.test(s || "")), `die Sorte steht als Wort daneben, nicht nur als Farbe (${stand.schilder.join(" / ")})`); ok(stand.offen >= 1, `${stand.offen} Zeile(n) melden offene Datensätze`); ok(/Art\. 6|Art\. 17/.test(stand.text), "die Rechtsgrundlage steht dabei"); /* DER KNOPF MUSS WIRKEN -- gemessen an der Zahl, nicht am Gefuehl. */ await seite.click("#aufb-jetzt"); await seite.waitForFunction( () => !document.querySelector('.aufb__zahl[data-offen="ja"]'), null, { timeout: 8000 }).catch(() => {}); const nachher = await seite.evaluate(() => document.querySelectorAll('.aufb__zahl[data-offen="ja"]').length); ok(nachher === 0, `nach dem Knopf wartet nichts mehr (${nachher})`); const d3 = oeffne(); const weg = d3.prepare("SELECT ip FROM protokoll WHERE aktion = 'test_bildschirm'").get(); d3.close(); ok(weg && weg.ip === null, "und die IP ist in der Datenbank wirklich weg"); ok(konsole.length === 0, konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 90) : "keine Fehler im Browser"); await browser.close(); } /* ------------------------------------------------------------------- */ 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);