Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
158 lines
8.5 KiB
TypeScript
158 lines
8.5 KiB
TypeScript
// Backup-Portal-Lane: Enqueue-/Kontroll-Logik + DSGVO-Zustellpaket.
|
|
//
|
|
// Deckt OHNE laufenden BullMQ-Worker ab (KONZEPT §4-Kontrollen + §5-Zustellung):
|
|
// (1) Getippte Bestätigung „RESTORE <slug>" — 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<string>();
|
|
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.asset.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 <slug>\":");
|
|
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.asset.create({ data: { tenantId: tenant.id, name: "Asset der Person", type: "SYSTEM", ownerId: user.id, createdBy: user.id } });
|
|
|
|
// ── (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);
|
|
});
|