From 7b09d752f8a121018770b9e52bb33c6bf3c2fcde Mon Sep 17 00:00:00 2001 From: Dogfather Date: Wed, 26 Aug 2026 19:55:05 +0200 Subject: [PATCH] Auskunft und Loeschung nach DSGVO (Art. 15/17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schreibt jemand "Welche Daten haben Sie ueber mich?" oder "Bitte loeschen Sie meine Daten", laeuft eine Frist von einem Monat (Art. 12 Abs. 3 DSGVO). Bisher haette man dafuer von Hand durch ein Dutzend Tabellen suchen muessen -- und uebersieht man eine, ist die Auskunft unvollstaendig, ohne dass man es ihr ansieht. ⚠️ LOESCHEN IST NICHT EINFACH LOESCHEN Der naheliegende Weg waere ein "Alles loeschen". Das waere bequem und doppelt falsch: Rechnungen und Buchungsbelege unterliegen einer gesetzlichen Aufbewahrungspflicht -- § 147 Abs. 3 AO nennt acht Jahre fuer Buchungsbelege, zehn fuer Handelsbuecher, gerechnet ab dem Ende des Kalenderjahres (Abs. 4). Wer sie auf Zuruf loescht, verstoesst gegen Steuerrecht, um Datenschutzrecht zu erfuellen. Die DSGVO nimmt diesen Fall selbst aus (Art. 17 Abs. 3 lit. b). Fuer das, was bleiben muss, sieht sie die Einschraenkung der Verarbeitung vor (Art. 18) -- genau so wird es ausgewiesen, samt Datum, ab dem geloescht werden darf. Dasselbe bei Widerrufserklaerungen: Sie sind der Nachweis, DASS und WANN widerrufen wurde. Wer sie loescht, vernichtet seinen eigenen Beleg in genau der Sache, in der es spaeter Streit geben koennte. EIN FEHLER, DEN DER TEST VOR DEM ERSTEN LAUF GEFUNDEN HAT Die Tabelle wd_widerrufe fuehrt die Adresse nicht als "email", sondern als "kontakt". Die erste Fassung suchte nach "email" -- sie haette dort NIE einen Treffer geliefert, und die Auskunft haette trotzdem sauber ausgesehen. Aufgefallen nur, weil die Testdaten gegen das echte Schema angelegt wurden statt gegen die Annahme. Deshalb steht in der Quellenliste jetzt die Spalte, nicht eine Vermutung. WEITERE ENTSCHEIDUNGEN - Die Suche geht ueber die Adresse UND ueber die Kundenkennung. Vieles haengt nicht an der Adresse; ohne diesen Schritt bliebe die halbe Auskunft leer. - Gross- und Kleinschreibung spielt keine Rolle -- sonst bekaeme jemand eine leere Auskunft, weil er seine Adresse anders schreibt. - Die Loeschung verlangt die Adresse ein zweites Mal. Der Unterschied zwischen .org und .com ist ein Buchstabe, die Folge unwiederbringlich. - Sie laeuft in einer Transaktion: Bricht sie ab, bliebe sonst ein halbgeloeschter Bestand, und niemand wuesste, welcher Teil weg ist. - Der Vorgang wird protokolliert, aber OHNE die geloeschten Inhalte -- sie dabei erneut zu speichern waere das Gegenteil des Zwecks. 15 Pruefungen gruen, mit Gegenprobe (fremde Adresse liefert nichts). Die Oberflaeche in der Verwaltung folgt. Deploy braucht Filipe: server-internal liegt unter /home/dogiintern mit Rechten 700. Co-Authored-By: Claude Opus 5 --- server-internal/index.js | 13 + .../lib/webdesign-personendaten.js | 234 ++++++++++++++++++ .../routes/webdesign-datenschutz.js | 144 +++++++++++ server-internal/test-personendaten.mjs | 149 +++++++++++ 4 files changed, 540 insertions(+) create mode 100644 server-internal/lib/webdesign-personendaten.js create mode 100644 server-internal/routes/webdesign-datenschutz.js create mode 100644 server-internal/test-personendaten.mjs 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);