Files
craftvia/scripts/test-backup-dsgvo.ts
T
msolarczekandClaude Opus 5 8491c7f173 Fundament: ISMS-Module entfernt; Craftvia-Rollen, Module, Navigation, i18n-Split
- ISMS-Routen, Actions, Server-/Lib-Code, Komponenten, Prisma-Modelle, Seeds,
  Importer, Skripte und ISMS-Tests entfernt (Fundament bleibt: Auth, Identity,
  MFA/WebAuthn, RBAC, Audit, Mail, Storage, Backup/DSGVO, Plattform-Admin)
- Schema auf Fundament-Modelle reduziert; TenantSettings generisch (+phone/email)
- TENANT_MODELS (db.ts, backup/topology.ts) und PII-Felder ausgedünnt
- RBAC: Rollen tenant-admin/backoffice/team-lead/technician + Craftvia-Permissions
- Modul-Katalog (customers, sites, teams, work_orders, imports, field, reports,
  emergency, documents, notifications, lotse) + Navigation aus src/lib/nav.ts
- Modul-Routen mit requireModule-Layout und Platzhalterseite
- Message-Katalog je Namespace (messages/<locale>/<namespace>.json), fs-Loader
- check-module-guards: Modul-Key aus src/server/actions/<moduleKey>/
- Provisionierung, Admin-Konsole, Einstellungen, Files-Route, Mail entkoppelt
- Seed minimal (demo/demo2, Nutzer je Rolle); Fundament-Tests auf Role/
  NotificationPreference-Fixtures umgestellt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:35:44 +02:00

119 lines
5.8 KiB
TypeScript

// DSGVO-Grundgerüst: Auskunft (Art. 15/20), Löschung/Anonymisierung (Art. 17)
// und der restore-feste Tombstone (KONZEPT §6). Arbeitet auf einem WEGWERF-
// Mandanten (Slug zz-dsgvo-*) — demo/demo2 bleiben unberührt.
//
// Lauf: npx tsx scripts/test-backup-dsgvo.ts
import "dotenv/config";
import { prisma } from "../src/server/db";
import { exportTenant } from "../src/server/backup/export";
import { restoreTenant } from "../src/server/backup/restore";
import { exportSubject } from "../src/server/dsgvo/export";
import { deleteSubject } from "../src/server/dsgvo/deletion";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
const SLUG = "zz-dsgvo-test";
const EMAIL = "zz-dsgvo-person@example.test";
async function cleanup() {
const t = await prisma.tenant.findUnique({ where: { slug: SLUG }, select: { id: true } });
const identityIds = new Set<string>();
if (t) {
for (const u of await prisma.user.findMany({ where: { tenantId: t.id }, select: { identityId: true } })) {
identityIds.add(u.identityId);
}
await prisma.tombstoneEntry.deleteMany({ where: { tenantId: t.id } });
await prisma.deletionCertificate.deleteMany({ where: { tenantId: t.id } });
await prisma.auditLog.deleteMany({ where: { tenantId: t.id } });
await prisma.notificationPreference.deleteMany({ where: { tenantId: t.id } });
await prisma.userRole.deleteMany({ where: { user: { tenantId: t.id } } });
await prisma.user.deleteMany({ where: { tenantId: t.id } });
await prisma.role.deleteMany({ where: { tenantId: t.id } });
await prisma.tenant.delete({ where: { id: t.id } });
}
// Identity per E-Mail (frischer Lauf) UND per gesammelter Id (ggf. schon anonymisiert).
const byEmail = await prisma.identity.findUnique({ where: { email: EMAIL }, select: { id: true } });
if (byEmail) identityIds.add(byEmail.id);
for (const id of identityIds) {
if ((await prisma.user.count({ where: { identityId: id } })) === 0) {
await prisma.identity.delete({ where: { id } }).catch(() => {});
}
}
}
async function main() {
await cleanup();
// Fixture: Mandant + Identity + Mitgliedschaft + Rolle + NotificationPreference + AuditLog.
const tenant = await prisma.tenant.create({ data: { name: "DSGVO Test", slug: SLUG } });
const identity = await prisma.identity.create({
data: { email: EMAIL, passwordHash: "x", status: "ACTIVE" },
});
const user = await prisma.user.create({
data: { tenantId: tenant.id, identityId: identity.id, email: EMAIL, name: "Test Person" },
});
const role = await prisma.role.create({ data: { tenantId: tenant.id, key: "member", name: "Member" } });
await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } });
await prisma.notificationPreference.create({
data: { tenantId: tenant.id, userId: user.id, eventType: "work_order_assigned", email: false },
});
await prisma.auditLog.create({
data: { tenantId: tenant.id, actorId: user.id, action: "create", entity: "user" },
});
// 1. Auskunft (Art. 15/20).
const subj = await exportSubject(tenant.id, identity.id);
ok(subj.identity?.email === EMAIL, "Auskunft enthält Identity-Metadaten (E-Mail)");
ok(!("passwordHash" in (subj.identity ?? {})), "Auskunft enthält KEINE Secrets (passwordHash)");
ok(subj.memberships.length === 1, "Auskunft enthält die Mitgliedschaft");
ok(!!subj.references["NotificationPreference.userId"], "Auskunft findet NotificationPreference über userId");
ok(!!subj.references["AuditLog.actorId"], "Auskunft findet AuditLog über actorId");
// Snapshot MIT Original-PII (für den Tombstone-on-Restore-Beweis).
const snap = await exportTenant(tenant.id, { persist: false, reason: "dsgvo-test" });
// 2. Löschung/Anonymisierung (Art. 17).
const del = await deleteSubject(tenant.id, identity.id, { reason: "test" });
ok(del.anonymizedMemberships === 1, "Löschung anonymisiert die Mitgliedschaft");
ok(del.identityAnonymized, "Globale Identity anonymisiert (keine Rest-Mitgliedschaft)");
const userAfter = await prisma.user.findUnique({ where: { id: user.id } });
ok(userAfter?.name === "Gelöschte Person" && userAfter?.email !== EMAIL, "User-Zeile ist anonymisiert (Name/E-Mail Tombstone)");
const idnAfter = await prisma.identity.findUnique({ where: { id: identity.id } });
ok(idnAfter?.email !== EMAIL && idnAfter?.status === "DISABLED", "Identity anonymisiert + DISABLED");
const cert = await prisma.deletionCertificate.findUnique({ where: { id: del.certificateId } });
ok(cert?.scope === "person", "Löschnachweis (DeletionCertificate) erstellt");
const tomb = await prisma.tombstoneEntry.findFirst({ where: { tenantId: tenant.id, model: "User", targetValue: user.id } });
ok(tomb?.action === "anonymize", "Tombstone-Eintrag gesetzt (restore-fest)");
// 3. BEWEIS Tombstone-on-Restore: alter Snapshot bringt PII NICHT zurück.
const res = await restoreTenant(tenant.id, { artifact: snap.artifact, manifest: snap.manifest }, { skipPreRestore: true });
ok(res.tombstonesReapplied >= 1, `Restore wendet Tombstones erneut an (${res.tombstonesReapplied})`);
const userReborn = await prisma.user.findUnique({ where: { id: user.id } });
ok(userReborn != null, "User-Zeile nach Restore wieder vorhanden (referenzielle Struktur)");
ok(userReborn?.name === "Gelöschte Person" && userReborn?.email !== EMAIL, "PII bleibt trotz altem Snapshot getilgt (Tombstone erneut angewandt)");
}
main()
.then(async () => {
await cleanup();
await prisma.$disconnect();
if (failures > 0) {
console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`);
process.exit(1);
}
console.log("\nOK");
})
.catch(async (e) => {
console.error(e);
await cleanup().catch(() => {});
await prisma.$disconnect();
process.exit(1);
});