// Backup-Portal-Lane: Enqueue-/Kontroll-Logik + DSGVO-Zustellpaket. // // Deckt OHNE laufenden BullMQ-Worker ab (KONZEPT §4-Kontrollen + §5-Zustellung): // (1) Getippte Bestätigung „RESTORE " — exakter Match (Kontrolle 3). // (2) MFA-Step-up mit Replay-Schutz: frischer Code ok, verbrauchter/älterer // Zeitschritt wird abgelehnt (Kontrolle 2). // (3) ZIP-Writer: rundläuft durch das System-`unzip` (echtes ZIP-Format). // (4) DSGVO-Zustellpaket (Per-Person) enthält die erwarteten Einträge OHNE // Secrets; der dsgvo_export-Job erzeugt Store-Objekt + signierten Token // mit kurzer TTL (processBackupJob direkt, ohne Redis). // // Lauf: npx tsx scripts/test-backup-portal.ts (nutzt lokale isms-DB, räumt auf) import "dotenv/config"; import { execFileSync } from "node:child_process"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { generateSync } from "otplib"; import { prisma } from "../src/server/db"; import { verifyTotp, newTotpSecret } from "../src/server/mfa"; import { matchesRestoreConfirmation, restoreConfirmationFor } from "../src/server/backup/restore-confirm"; import { buildZip } from "../src/server/backup/zip"; import { buildDsgvoPackage } from "../src/server/backup/dsgvo-zip"; import { processBackupJob, dsgvoPackageKey } from "../src/server/backup/ops"; import { getBackupStore } from "../src/server/storage/backup-store"; let failures = 0; const ok = (cond: boolean, msg: string) => { console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); if (!cond) failures++; }; const SLUG = "zz-portal-test"; const EMAIL = "zz-portal-person@example.test"; async function cleanup() { const t = await prisma.tenant.findUnique({ where: { slug: SLUG }, select: { id: true } }); const identityIds = new Set(); if (t) { for (const u of await prisma.user.findMany({ where: { tenantId: t.id }, select: { identityId: true } })) identityIds.add(u.identityId); await prisma.backupJob.deleteMany({ where: { tenantId: t.id } }); 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 } }); await (await getBackupStore()).remove(`${t.id}/`); } 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(); // ── (1) Getippte Bestätigung ──────────────────────────────────────────────── console.log("\n(1) Getippte Bestätigung „RESTORE \":"); ok(matchesRestoreConfirmation("kunde-a", "RESTORE kunde-a"), "exakter Match akzeptiert"); ok(!matchesRestoreConfirmation("kunde-a", "RESTORE kunde-b"), "falscher Slug abgelehnt"); ok(!matchesRestoreConfirmation("kunde-a", "restore kunde-a"), "abweichende Groß-/Kleinschreibung abgelehnt"); ok(!matchesRestoreConfirmation("kunde-a", " RESTORE kunde-a "), "Leerzeichen-Abweichung abgelehnt"); ok(restoreConfirmationFor("kunde-a") === "RESTORE kunde-a", "Erwartungstext korrekt gebildet"); // ── (2) MFA-Step-up + Replay-Schutz ───────────────────────────────────────── console.log("\n(2) MFA-Step-up (Replay-Schutz über lastTotpStep):"); const secret = newTotpSecret(); const code = generateSync({ secret }); const first = verifyTotp(code, secret); ok(first.ok, "frischer TOTP-Code wird akzeptiert"); ok(!verifyTotp("000000", secret).ok, "falscher Code abgelehnt"); if (first.ok) { const replay = verifyTotp(code, secret, first.step); ok(!replay.ok, "verbrauchter Zeitschritt (Replay) wird abgelehnt"); } // ── (3) ZIP-Writer rundläuft durch System-unzip ──────────────────────────── console.log("\n(3) ZIP-Writer (Standardformat):"); const zip = buildZip([ { name: "hello.txt", data: "Hallo Welt" }, { name: "data/inner.json", data: JSON.stringify({ a: 1 }) }, ]); const dir = mkdtempSync(join(tmpdir(), "cvzip-")); const zipPath = join(dir, "t.zip"); writeFileSync(zipPath, zip); let unzipOk = true; let content = ""; try { const list = execFileSync("unzip", ["-l", zipPath], { encoding: "utf8" }); unzipOk = list.includes("hello.txt") && list.includes("data/inner.json"); content = execFileSync("unzip", ["-p", zipPath, "hello.txt"], { encoding: "utf8" }); } catch (e) { unzipOk = false; console.warn(" (unzip nicht verfügbar/ Fehler:", e instanceof Error ? e.message : e, ")"); } ok(unzipOk, "ZIP von System-`unzip` gelesen (beide Einträge vorhanden)"); ok(content.trim() === "Hallo Welt", "entpackter Inhalt stimmt (CRC/Deflate korrekt)"); rmSync(dir, { recursive: true, force: true }); // ── Fixture-Mandant für DSGVO ────────────────────────────────────────────── const tenant = await prisma.tenant.create({ data: { name: "Portal 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: "Portal Person" } }); await prisma.notificationPreference.create({ data: { tenantId: tenant.id, userId: user.id, eventType: "work_order_assigned", email: false } }); // ── (4a) DSGVO-Paket (Per-Person) direkt ──────────────────────────────────── console.log("\n(4) DSGVO-Zustellpaket:"); const pkg = await buildDsgvoPackage(tenant.id, identity.id); ok(pkg.summary.scope === "person", "Scope=person erkannt"); const pdir = mkdtempSync(join(tmpdir(), "cvdsgvo-")); const ppath = join(pdir, "p.zip"); writeFileSync(ppath, pkg.zip); let names = ""; let identityJson = ""; try { names = execFileSync("unzip", ["-l", ppath], { encoding: "utf8" }); identityJson = execFileSync("unzip", ["-p", ppath, "identity.json"], { encoding: "utf8" }); } catch { /* ohne unzip überspringen */ } ok(names.includes("identity.json") && names.includes("memberships.json") && names.includes("references.json"), "Paket enthält identity/memberships/references"); ok(identityJson.includes(EMAIL), "identity.json enthält die E-Mail der Person"); ok(!identityJson.includes("passwordHash") && !identityJson.includes("mfaSecret"), "identity.json enthält KEINE Secrets"); rmSync(pdir, { recursive: true, force: true }); // ── (4b) dsgvo_export-Job (processBackupJob, ohne Redis) → Store + Token/TTL ─ const job = await prisma.backupJob.create({ data: { kind: "dsgvo_export", status: "queued", tenantId: tenant.id, tenantSlug: tenant.slug, subjectIdentityId: identity.id, actorId: null }, }); await processBackupJob({ kind: "dsgvo_export", jobId: job.id, tenantId: tenant.id, subjectIdentityId: identity.id, actorId: "test-admin" }); const done = await prisma.backupJob.findUnique({ where: { id: job.id } }); ok(done?.status === "done", "dsgvo_export-Job auf 'done' gesetzt"); ok(!!done?.downloadToken && done.downloadToken.length >= 20, "signierter Download-Token erzeugt"); ok(!!done?.downloadExpiresAt && done.downloadExpiresAt.getTime() > Date.now(), "TTL/Ablaufzeitpunkt in der Zukunft"); const stored = await (await getBackupStore()).get(dsgvoPackageKey(tenant.id, job.id)); ok(!!stored && stored.length > 0, "ZIP im Backup-Store abgelegt (für den Download-Endpunkt)"); } 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); });