diff --git a/server-internal/index.js b/server-internal/index.js index 2f7d6b58..2aed73b7 100644 --- a/server-internal/index.js +++ b/server-internal/index.js @@ -92,6 +92,12 @@ import { import { pushStand, pushSchluessel, pushAnmelden, pushAbmelden, pushTest, } from "./routes/webdesign-push.js"; +/* Auskunft und Loeschung (DSGVO Art. 15/17, 26.08.2026). Beides + verlangt eine Antwort binnen eines Monats -- von Hand durch ein + Dutzend Tabellen zu suchen ist dafuer zu langsam und zu unsicher. */ +import { + dsAuskunft, dsLoeschvorschau, dsLoeschen, +} from "./routes/webdesign-datenschutz.js"; /* Kundenportal (Masterplan S.12). Eigene Anmeldung mit eigener Sitzung -- Kunden sind KEINE Teammitglieder und duerfen mit deren Rechtesystem nichts zu tun haben. Die Mandantentrennung sitzt in den Handlern: @@ -333,6 +339,13 @@ app.post("/webdesign/admin/push/anmelden", wrap(pushAnmelden)); app.post("/webdesign/admin/push/abmelden", wrap(pushAbmelden)); app.post("/webdesign/admin/push/test", wrap(pushTest)); +/* Datenschutz-Auskunft und -Loeschung. Die Reihenfolge ist Absicht: + erst sehen, dann Vorschau, dann -- mit doppelter Bestaetigung -- + ausfuehren. Eine Loeschung ist endgueltig. */ +app.post("/webdesign/admin/datenschutz/auskunft", wrap(dsAuskunft)); +app.post("/webdesign/admin/datenschutz/vorschau", wrap(dsLoeschvorschau)); +app.post("/webdesign/admin/datenschutz/loeschen", wrap(dsLoeschen)); + /* Annehmen und Ablehnen. Die Vorschau-Endpunkte lesen nur und veraendern nichts -- deshalb GET. diff --git a/server-internal/lib/webdesign-personendaten.js b/server-internal/lib/webdesign-personendaten.js new file mode 100644 index 00000000..f69a289e --- /dev/null +++ b/server-internal/lib/webdesign-personendaten.js @@ -0,0 +1,234 @@ +/* ===================================================================== + webdesign-personendaten.js — Auskunft und Löschung (DSGVO Art. 15/17) + + WORUM ES GEHT + + Schreibt jemand "Welche Daten haben Sie über mich?" oder "Bitte + löschen Sie meine Daten", läuft eine Frist von einem Monat + (Art. 12 Abs. 3 DSGVO). Bisher hätte man dafür von Hand durch ein + Dutzend Tabellen suchen müssen — fehleranfällig, und übersieht man + eine, ist die Auskunft unvollständig. + + ⚠️ WARUM LÖSCHEN NICHT EINFACH LÖSCHEN IST + + Der häufigste Fehler an dieser Stelle ist, ein "Alles löschen" zu + bauen. Das wäre bequem und gleich doppelt falsch: + + Rechnungen und Buchungsbelege unterliegen einer gesetzlichen + Aufbewahrungspflicht — § 147 Abs. 3 AO nennt acht Jahre für + Buchungsbelege, zehn für Handelsbücher und Jahresabschlüsse, + gerechnet ab dem Ende des Kalenderjahres (§ 147 Abs. 4 AO). Wer sie + auf Zuruf löscht, verstößt gegen Steuerrecht, um Datenschutzrecht zu + erfüllen. Die DSGVO selbst nimmt diesen Fall ausdrücklich aus: + Art. 17 Abs. 3 lit. b gilt, "solange die Verarbeitung zur Erfüllung + einer rechtlichen Verpflichtung erforderlich ist". + + Dasselbe bei Widerrufserklärungen: Sie sind der Nachweis, DASS und + WANN widerrufen wurde. Wer sie löscht, vernichtet seinen eigenen + Beleg in genau der Sache, in der es später Streit geben könnte. + + Deshalb trennt dieses Modul strikt: + + löschbar — geht sofort weg + aufzubewahren — bleibt, mit Grund und Datum der Freigabe + + Für das, was bleiben muss, sieht die DSGVO die Einschränkung der + Verarbeitung vor (Art. 18): Die Daten werden nicht mehr benutzt, nur + noch aufbewahrt. Genau das wird hier gekennzeichnet. + + NICHTS WIRD AUTOMATISCH GELÖSCHT + + Es gibt eine Vorschau, und erst danach eine Entscheidung durch einen + Menschen. Eine Löschung ist endgültig; sie gehört nicht hinter einen + Knopf, der sofort zuschlägt. + ===================================================================== */ + +import { db } from "../db.js"; + +/* --------------------------------------------------------------------- + WO ÜBERALL ETWAS ZU EINER PERSON STEHT + + Bewusst als Liste und nicht im Code verstreut: Eine Auskunft, die + eine Tabelle vergisst, ist falsch — und man sieht ihr das nicht an. + Kommt eine Tabelle dazu, gehört sie hier hinein. + + ueber: "email" — die Tabelle führt die Adresse selbst + "kunde_id" — sie hängt über die Kundenkennung daran + --------------------------------------------------------------------- */ +const QUELLEN = [ + { tabelle: "wd_anfragen", ueber: "email", was: "Projektanfragen" }, + { tabelle: "wd_kunden", ueber: "email", was: "Kundenkonto" }, + /* ⚠️ Hier heißt die Spalte "kontakt", nicht "email". + Mit "email" hätte diese Tabelle NIE einen Treffer geliefert — und + die Auskunft hätte trotzdem sauber ausgesehen. Aufgefallen beim + Anlegen der Testdaten, weil das Schema die Spalte nicht kannte; + im Betrieb wäre es niemandem aufgefallen. Genau deshalb steht in + dieser Liste jetzt die Spalte statt einer Annahme. */ + { tabelle: "wd_widerrufe", ueber: "spalte", spalte: "kontakt", + was: "Widerrufserklärungen", + aufbewahren: "Nachweis des Widerrufs (§ 355 BGB)" }, + { tabelle: "wd_projekte", ueber: "kunde_id", was: "Projekte" }, + { tabelle: "wd_angebote", ueber: "kunde_id", was: "Angebote" }, + { tabelle: "wd_zahlungen", ueber: "kunde_id", was: "Zahlungen und Rechnungen", + aufbewahren: "Buchungsbeleg, 8 Jahre ab Jahresende (§ 147 Abs. 3 Nr. 4 AO)" }, + { tabelle: "wd_postfach", ueber: "kunde_id", was: "Nachrichten" }, + { tabelle: "wd_nachrichten", ueber: "kunde_id", was: "Projektnachrichten" }, + { tabelle: "wd_aenderungen", ueber: "kunde_id", was: "Änderungswünsche" }, + { tabelle: "wd_aufgaben", ueber: "kunde_id", was: "Aufgaben" }, + { tabelle: "wd_dateien", ueber: "kunde_id", was: "Hochgeladene Dateien" }, + { tabelle: "wd_betreuung_abos", ueber: "kunde_id", was: "Betreuungsverträge", + aufbewahren: "Vertragsunterlage (§ 257 HGB)" }, + { tabelle: "wd_kunden_sitzungen", ueber: "kunde_id", was: "Anmeldungen im Portal" }, + { tabelle: "wd_kunden_token", ueber: "kunde_id", was: "Einladungs- und Zugangstoken" }, + { tabelle: "wd_anfrage_notizen", ueber: "anfrage", was: "Interne Notizen zu Anfragen" }, +]; + +/* Ob eine Tabelle existiert. Das Schema wächst über Migrationen; eine + fehlende Tabelle darf die Auskunft nicht abbrechen lassen, sondern + muss übersprungen werden. */ +function tabelleDa(name) { + const z = db.prepare( + `SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?` + ).get(name); + return !!z; +} + +function spaltenVon(tabelle) { + try { + return db.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name); + } catch { + return []; + } +} + +/* --------------------------------------------------------------------- + ALLES ZU EINER PERSON ZUSAMMENTRAGEN + --------------------------------------------------------------------- */ +export function datenZuPerson(email) { + const adresse = String(email || "").trim().toLowerCase(); + if (!adresse || !adresse.includes("@")) { + throw new Error("Bitte eine E-Mail-Adresse angeben."); + } + + /* Erst die Kundenkennungen: Vieles hängt nicht an der Adresse, + sondern an ihnen. Ohne diesen Schritt bliebe die halbe Auskunft + leer, ohne dass es auffiele. */ + let kundenIds = []; + if (tabelleDa("wd_kunden")) { + kundenIds = db.prepare(`SELECT id FROM wd_kunden WHERE lower(email) = ?`) + .all(adresse).map((z) => z.id); + } + + /* Anfragen können auch ohne Kundenkonto bestehen. */ + let anfrageIds = []; + if (tabelleDa("wd_anfragen")) { + anfrageIds = db.prepare(`SELECT id FROM wd_anfragen WHERE lower(email) = ?`) + .all(adresse).map((z) => z.id); + } + + const gefunden = []; + + for (const q of QUELLEN) { + if (!tabelleDa(q.tabelle)) continue; + const spalten = spaltenVon(q.tabelle); + let zeilen = []; + + try { + if (q.ueber === "email" && spalten.includes("email")) { + zeilen = db.prepare(`SELECT * FROM ${q.tabelle} WHERE lower(email) = ?`).all(adresse); + } else if (q.ueber === "spalte" && spalten.includes(q.spalte)) { + /* Für Tabellen, die die Adresse unter anderem Namen führen. */ + zeilen = db.prepare(`SELECT * FROM ${q.tabelle} WHERE lower(${q.spalte}) = ?`).all(adresse); + } else if (q.ueber === "kunde_id" && spalten.includes("kunde_id") && kundenIds.length) { + const platz = kundenIds.map(() => "?").join(","); + zeilen = db.prepare(`SELECT * FROM ${q.tabelle} WHERE kunde_id IN (${platz})`).all(...kundenIds); + } else if (q.ueber === "anfrage" && spalten.includes("anfrage_id") && anfrageIds.length) { + const platz = anfrageIds.map(() => "?").join(","); + zeilen = db.prepare(`SELECT * FROM ${q.tabelle} WHERE anfrage_id IN (${platz})`).all(...anfrageIds); + } + } catch (e) { + /* Eine Tabelle, deren Aufbau abweicht, darf die Auskunft nicht + zum Abbruch bringen -- sie wird gemeldet, damit die Lücke + sichtbar ist statt still zu bleiben. */ + gefunden.push({ + tabelle: q.tabelle, was: q.was, anzahl: null, + fehler: String(e.message).slice(0, 120), + }); + continue; + } + + if (!zeilen.length) continue; + + gefunden.push({ + tabelle: q.tabelle, + was: q.was, + anzahl: zeilen.length, + aufbewahren: q.aufbewahren || null, + zeilen, + }); + } + + return { + email: adresse, + kundenIds, + anfrageIds, + erstellt: new Date().toISOString(), + bereiche: gefunden, + /* Die Zahl, die man zuerst wissen will. */ + datensaetze: gefunden.reduce((s, b) => s + (b.anzahl || 0), 0), + }; +} + +/* --------------------------------------------------------------------- + WAS BEI EINER LÖSCHUNG GESCHÄHE + + Nur eine Vorschau. Sie trennt in das, was weg darf, und das, was + bleiben muss -- mit Grund und mit dem Datum, ab dem es weg darf. + --------------------------------------------------------------------- */ +export function loeschvorschau(email) { + const daten = datenZuPerson(email); + const loeschbar = []; + const bleibt = []; + + for (const b of daten.bereiche) { + if (!b.anzahl) continue; + if (b.aufbewahren) { + /* Frist ab Ende des Kalenderjahres (§ 147 Abs. 4 AO). Genommen + wird der jüngste Eintrag -- solange einer davon noch in der + Frist liegt, bleibt der ganze Bereich. */ + const jahre = /10 Jahre|§ 257/.test(b.aufbewahren) ? 10 : 8; + let juengstes = null; + for (const z of b.zeilen) { + /* Die Tabellen benennen ihr Datum unterschiedlich. Fehlt es + hier, faellt die Frist auf das laufende Jahr zurueck -- also + auf die laengere Aufbewahrung. Im Zweifel lieber zu lange + behalten als zu frueh loeschen. */ + const d = z.erstellt_am || z.bezahlt_am || z.eingegangen_am + || z.erklaert_am || z.datum; + if (d && (!juengstes || d > juengstes)) juengstes = d; + } + const jahr = juengstes ? new Date(juengstes).getFullYear() : new Date().getFullYear(); + bleibt.push({ + was: b.was, anzahl: b.anzahl, grund: b.aufbewahren, + /* 31.12. des Jahres + Frist. */ + freiAb: `${jahr + jahre}-12-31`, + }); + } else { + loeschbar.push({ was: b.was, anzahl: b.anzahl, tabelle: b.tabelle }); + } + } + + return { + email: daten.email, + gefunden: daten.datensaetze, + loeschbar, + bleibt, + /* Der Satz, den man in die Antwort an die Person schreiben kann. */ + hinweis: bleibt.length + ? "Ein Teil der Daten unterliegt einer gesetzlichen Aufbewahrungspflicht und " + + "kann erst danach gelöscht werden. Bis dahin wird die Verarbeitung " + + "eingeschränkt (Art. 18 DSGVO): Die Daten werden nur noch aufbewahrt, " + + "nicht mehr verwendet." + : "Alle gefundenen Daten können gelöscht werden.", + }; +} diff --git a/server-internal/routes/webdesign-datenschutz.js b/server-internal/routes/webdesign-datenschutz.js new file mode 100644 index 00000000..4e31c154 --- /dev/null +++ b/server-internal/routes/webdesign-datenschutz.js @@ -0,0 +1,144 @@ +/* ===================================================================== + webdesign-datenschutz.js — Auskunft und Löschung (DSGVO Art. 15/17) + + Drei Endpunkte, und die Reihenfolge ist Absicht: + + auskunft — was ist gespeichert (unschädlich, jederzeit) + loeschvorschau — was geschähe bei einer Löschung + loeschen — führt aus, und nur mit ausdrücklicher Bestätigung + + Eine Löschung ist endgültig. Sie gehört nicht hinter einen Knopf, der + sofort zuschlägt — deshalb ist die Vorschau ein eigener Schritt und + die Ausführung verlangt zusätzlich die Adresse als Bestätigung, so + wie man es beim Löschen eines Kontos kennt. + ===================================================================== */ + +import { db } from "../db.js"; +import { json } from "../lib/http.js"; +import { requireSession } from "../lib/auth.js"; +import { hasPermission } from "../lib/permissions.js"; +import { ausweisPruefen } from "../lib/webdesign-ausweis.js"; +import { text, verlauf } from "../lib/webdesign-helfer.js"; +import { datenZuPerson, loeschvorschau } from "../lib/webdesign-personendaten.js"; + +async function darf(req, recht) { + const ausweis = ausweisPruefen(req); + if (ausweis) return { erlaubt: true, sitzung: { actor: ausweis.rolle, isOwner: true } }; + const sitzung = await requireSession(req); + if (!sitzung) return { erlaubt: false, grund: 401, sitzung: null }; + if (sitzung.isOwner) return { erlaubt: true, sitzung }; + const ok = hasPermission(sitzung.permissions, sitzung.overrides, recht); + return { erlaubt: ok, grund: ok ? null : 403, sitzung }; +} + +/* Auskunft und Löschung berühren ALLE Kundendaten. Das ist bewusst an + das Recht zum Verwalten von Kunden geknüpft, nicht an das blosse + Ansehen von Anfragen. */ +const RECHT = "WD_KUNDEN_MANAGE"; + +export async function dsAuskunft(req, res) { + const p = await darf(req, RECHT); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + try { + const daten = datenZuPerson(text(req.body?.email, 200)); + + /* Der Zugriff wird protokolliert. Eine Auskunft ist ein + Verwaltungsvorgang mit Frist -- man will später belegen können, + wann sie erstellt wurde. */ + verlauf("datenschutz", daten.email, "auskunft erstellt", + p.sitzung?.actor || "unbekannt", { datensaetze: daten.datensaetze }); + + return json(res, { ok: true, ...daten }); + } catch (e) { + return json(res, { ok: false, error: e.message || "Fehlgeschlagen." }, 400); + } +} + +export async function dsLoeschvorschau(req, res) { + const p = await darf(req, RECHT); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + try { + return json(res, { ok: true, ...loeschvorschau(text(req.body?.email, 200)) }); + } catch (e) { + return json(res, { ok: false, error: e.message || "Fehlgeschlagen." }, 400); + } +} + +export async function dsLoeschen(req, res) { + const p = await darf(req, RECHT); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + const email = text(req.body?.email, 200); + const bestaetigung = text(req.body?.bestaetigung, 200); + + /* Die Adresse muss ein zweites Mal eingegeben werden. Das ist keine + Schikane: Der Unterschied zwischen "anna@example.org" und + "anna@example.com" ist ein Buchstabe, und die Folge wäre, dass die + Daten der falschen Person gelöscht werden -- unwiederbringlich. */ + if (!email || email.toLowerCase() !== bestaetigung.toLowerCase()) { + return json(res, { + ok: false, + error: "Zur Bestätigung bitte dieselbe E-Mail-Adresse ein zweites Mal eingeben.", + }, 400); + } + + let vorschau; + try { + vorschau = loeschvorschau(email); + } catch (e) { + return json(res, { ok: false, error: e.message }, 400); + } + + if (!vorschau.loeschbar.length) { + return json(res, { + ok: true, geloescht: 0, + hinweis: vorschau.bleibt.length + ? "Es gibt nichts zu löschen — alles Gefundene unterliegt einer Aufbewahrungspflicht." + : "Zu dieser Adresse ist nichts gespeichert.", + bleibt: vorschau.bleibt, + }); + } + + /* Alles oder nichts. Bricht es mittendrin ab, bliebe ein + halbgelöschter Bestand zurück -- und niemand wüsste, welcher Teil + schon weg ist. */ + const daten = datenZuPerson(email); + let geloescht = 0; + + const durchfuehren = db.transaction(() => { + for (const bereich of vorschau.loeschbar) { + const b = daten.bereiche.find((x) => x.tabelle === bereich.tabelle); + if (!b) continue; + for (const zeile of b.zeilen) { + if (!zeile.id) continue; + const r = db.prepare(`DELETE FROM ${bereich.tabelle} WHERE id = ?`).run(zeile.id); + geloescht += r.changes; + } + } + }); + + try { + durchfuehren(); + } catch (e) { + return json(res, { ok: false, error: "Löschung abgebrochen: " + e.message }, 500); + } + + /* Der Vorgang selbst wird festgehalten -- OHNE die gelöschten Inhalte. + Man muss belegen können, DASS und WANN gelöscht wurde; die Daten + dabei erneut zu speichern wäre das Gegenteil des Zwecks. */ + verlauf("datenschutz", email, "daten geloescht", + p.sitzung?.actor || "unbekannt", + { geloescht, bereiche: vorschau.loeschbar.map((x) => x.was), aufbewahrt: vorschau.bleibt.length }); + + return json(res, { + ok: true, + geloescht, + bleibt: vorschau.bleibt, + hinweis: vorschau.bleibt.length + ? "Gelöscht, soweit zulässig. Der Rest unterliegt einer Aufbewahrungspflicht " + + "und wird nur noch aufbewahrt, nicht mehr verwendet (Art. 18 DSGVO)." + : "Alle Daten zu dieser Adresse wurden gelöscht.", + }); +} diff --git a/server-internal/test-personendaten.mjs b/server-internal/test-personendaten.mjs new file mode 100644 index 00000000..84ff4295 --- /dev/null +++ b/server-internal/test-personendaten.mjs @@ -0,0 +1,149 @@ +/* ===================================================================== + Prüft Auskunft und Löschvorschau (DSGVO Art. 15/17). + + Der gefährlichste Fehler hier ist eine UNVOLLSTÄNDIGE Auskunft: Sie + sieht genauso aus wie eine vollständige. Wer eine Tabelle vergisst, + merkt es nicht — die Antwort wirkt sauber, ist aber falsch, und im + Streitfall steht man mit einer nachweislich lückenhaften Auskunft da. + + Deshalb wird hier eine Person angelegt, deren Daten sich über + mehrere Tabellen verteilen — auch über solche, die nicht die + E-Mail-Adresse führen, sondern nur die Kundenkennung. Genau die + werden beim Suchen von Hand übersehen. + + Der zweite Test ist die Trennung beim Löschen: Rechnungen und + Widerrufe dürfen NICHT im Löschbaren landen. + ===================================================================== */ +import crypto from "crypto"; +import fs from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TEST_DB = join(__dirname, "test-personen.db"); + +for (const e of ["", "-wal", "-shm"]) fs.rmSync(TEST_DB + e, { force: true }); +process.env.DB_PATH = TEST_DB; +process.env.ENCRYPTION_KEY = Buffer.from(crypto.randomBytes(32)).toString("base64"); + +const { db, initDb } = await import("./db.js"); +initDb(); + +const P = await import("./lib/webdesign-personendaten.js"); + +let ok = 0, fehl = 0; +const pruefe = (name, gut, info) => { + if (gut) { ok++; console.log(" ok " + name + (info ? " -> " + info : "")); } + else { fehl++; console.log(" FEHL " + name + (info ? " -> " + info : "")); } +}; + +/* --- Eine Person mit verteilten Spuren ---------------------------- */ +const MAIL = "anna.beispiel@example.org"; +const jetzt = new Date().toISOString(); +const kundeId = "k-test-1"; +const anfrageId = "a-test-1"; + +/* Genau die Pflichtfelder des echten Schemas — aus PRAGMA table_info + ausgelesen, nicht geraten. Beim ersten Versuch stand hier ein Feld + "nummer", das es in wd_kunden gar nicht gibt. */ +db.prepare(`INSERT INTO wd_kunden (id, email, name, firma, telefon, erstellt_am) + VALUES (?,?,?,?,?,?)`) + .run(kundeId, MAIL, "Anna Beispiel", "Beispiel GmbH", "0170 000", jetzt); + +db.prepare(`INSERT INTO wd_anfragen (id, nummer, name, email, paket, ziel, erstellt_am) + VALUES (?,?,?,?,?,?,?)`) + .run(anfrageId, "A-9001", "Anna Beispiel", MAIL, "shop", "Ein Shop.", jetzt); + +/* Diese hängen NUR über die Kundenkennung — der Fall, der beim Suchen + von Hand durchrutscht. */ +let mitZahlung = false; +try { + db.prepare(`INSERT INTO wd_zahlungen (id, nummer, kunde_id, art, zweck_text, betrag_cent, erstellt_am) + VALUES (?,?,?,?,?,?,?)`) + .run("z1", "R-9001", kundeId, "anzahlung", "Anzahlung Shop", 120000, jetzt); + mitZahlung = true; +} catch (e) { console.log(" (Zahlungstabelle abweichend: " + String(e.message).slice(0, 60) + ")"); } + +let mitWiderruf = false; +try { + /* ⚠️ Die Spalte heisst hier "kontakt", nicht "email". Genau daran + ist die erste Fassung des Moduls gescheitert -- sie haette + Widerrufe nie gefunden, und die Auskunft haette trotzdem sauber + ausgesehen. */ + db.prepare(`INSERT INTO wd_widerrufe (id, nummer, name, kontakt, vertrag, eingegangen_am) + VALUES (?,?,?,?,?,?)`) + .run("w1", "W-9001", "Anna Beispiel", MAIL, "P-9001", jetzt); + mitWiderruf = true; +} catch (e) { console.log(" (Widerrufstabelle abweichend: " + String(e.message).slice(0, 60) + ")"); } + +console.log("1. AUSKUNFT (Art. 15) — WIRD ALLES GEFUNDEN?"); + +const a = P.datenZuPerson(MAIL); +pruefe("Kundenkonto gefunden", a.bereiche.some((b) => b.tabelle === "wd_kunden")); +pruefe("Anfrage gefunden", a.bereiche.some((b) => b.tabelle === "wd_anfragen")); +pruefe("Kundenkennung ermittelt", a.kundenIds.includes(kundeId), a.kundenIds.join(",")); + +if (mitZahlung) { + pruefe("Zahlung ueber die Kundenkennung gefunden", + a.bereiche.some((b) => b.tabelle === "wd_zahlungen"), + "haengt NICHT an der E-Mail — der Fall, der von Hand durchrutscht"); +} +if (mitWiderruf) { + pruefe("Widerruf gefunden", a.bereiche.some((b) => b.tabelle === "wd_widerrufe")); +} + +pruefe("Anzahl wird ausgewiesen", a.datensaetze >= 2, a.datensaetze + " Datensaetze"); + +/* Gegenprobe: Eine fremde Adresse darf NICHTS liefern. Ohne sie könnte + die Abfrage alles zurückgeben und der Test wäre trotzdem grün. */ +const fremd = P.datenZuPerson("niemand@example.org"); +pruefe("fremde Adresse liefert nichts", fremd.datensaetze === 0, fremd.datensaetze + " Datensaetze"); + +/* Und Groß-/Kleinschreibung darf keine Rolle spielen — sonst bekommt + jemand eine leere Auskunft, weil er seine Adresse anders schreibt. */ +const gross = P.datenZuPerson(MAIL.toUpperCase()); +pruefe("Gross- und Kleinschreibung egal", gross.datensaetze === a.datensaetze, + gross.datensaetze + " gegen " + a.datensaetze); + +console.log("\n2. LOESCHVORSCHAU (Art. 17) — WIRD RICHTIG GETRENNT?"); + +const v = P.loeschvorschau(MAIL); +pruefe("Anfrage steht im Loeschbaren", + v.loeschbar.some((x) => x.tabelle === "wd_anfragen")); + +if (mitZahlung) { + /* Der wichtigste Test dieser Datei: Rechnungen dürfen NICHT + gelöscht werden. § 147 Abs. 3 AO. */ + pruefe("Zahlungen stehen NICHT im Loeschbaren", + !v.loeschbar.some((x) => x.tabelle === "wd_zahlungen")); + const z = v.bleibt.find((x) => /Zahlungen/.test(x.was)); + pruefe("Zahlungen sind als aufbewahrungspflichtig ausgewiesen", !!z, + z ? z.grund : "fehlt"); + pruefe("mit einem Datum, ab dem geloescht werden darf", + !!(z && /^\d{4}-12-31$/.test(z.freiAb)), z ? z.freiAb : "—"); +} + +if (mitWiderruf) { + pruefe("Widerrufe stehen NICHT im Loeschbaren", + !v.loeschbar.some((x) => x.tabelle === "wd_widerrufe")); +} + +pruefe("es gibt einen Hinweistext fuer die Antwort an die Person", + v.hinweis && v.hinweis.length > 40); + +if (mitZahlung || mitWiderruf) { + pruefe("der Hinweis nennt die Einschraenkung nach Art. 18", + /Art\. 18/.test(v.hinweis)); +} + +console.log("\n3. WAS DIE VORSCHAU AUSGIBT"); +console.log(" loeschbar:"); +for (const l of v.loeschbar) console.log(` ${l.anzahl}x ${l.was}`); +console.log(" bleibt:"); +for (const b of v.bleibt) console.log(` ${b.anzahl}x ${b.was} — ${b.grund}, frei ab ${b.freiAb}`); + +db.close(); +for (const e of ["", "-wal", "-shm"]) fs.rmSync(TEST_DB + e, { force: true }); + +console.log(`\n===== ${ok} bestanden, ${fehl} fehlgeschlagen =====`); +process.exit(fehl ? 1 : 0);