import "dotenv/config"; import { prisma } from "../src/server/db"; import { hashPassword, verifyPassword } from "../src/server/password"; import { redeemPasswordReset, requestPasswordReset } from "../src/server/actions/auth-recovery"; import { issueToken, peekToken } from "../src/server/auth-token"; import { resetRateLimits } from "../src/server/rate-limit"; import { closeQueues } from "../src/server/mail/queue"; import { closeMailProvider } from "../src/server/mail/provider-smtp"; /** * SEC2 — End-to-End des Reset-Ablaufs gegen ein Wegwerf-Konto * (`npx tsx scripts/test-reset-flow.ts`). * * Ergänzt `test-auth-selfservice.ts` (dort: die Token-/Limit-Eigenschaften * isoliert) um das Zusammenspiel: Anfrage → Token → Einlösung → Passwort * geschrieben → Sessions entwertet → Bestätigungsmail → Audit. * * Zusätzlich die beiden Fälle, die man leicht falsch baut: * - ein **Policy-Verstoß darf den Link nicht entwerten** (sonst ist der Nutzer * nach einem Tippfehler ausgesperrt), * - ein **deaktiviertes Konto** bekommt keinen Reset, aber dieselbe Antwort. * * Das Testkonto wird angelegt und am Ende restlos entfernt. */ const EMAIL = "sec2-e2e@example.test"; const OLD_PW = "Sec2-E2E-Alt-2026!"; const NEW_PW = "Sec2-E2E-Neu-2026!"; let failures = 0; function check(name: string, ok: boolean, detail?: string) { if (ok) console.log(` ✓ ${name}`); else { failures++; console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`); } } async function main() { resetRateLimits(); const tenant = await prisma.tenant.findFirst({ where: { status: "ACTIVE" } }); if (!tenant) throw new Error("Kein aktiver Mandant vorhanden — bitte zuerst seeden."); await prisma.user.deleteMany({ where: { email: EMAIL } }); await prisma.identity.deleteMany({ where: { email: EMAIL, memberships: { none: {} } } }); // Option C: Mitgliedschaft braucht eine globale Identity (Anmeldung). const identity = await prisma.identity.create({ data: { email: EMAIL, passwordHash: await hashPassword(OLD_PW) } }); const user = await prisma.user.create({ data: { tenantId: tenant.id, identityId: identity.id, email: EMAIL, name: "SEC2 E2E", status: "ACTIVE", }, }); try { console.log("1) Anfrage"); const fd = new FormData(); fd.set("email", EMAIL); fd.set("domain", "tenant"); const req = await requestPasswordReset({ status: "idle" }, fd); check("neutrale Antwort", req.status === "done"); const unknownEmail = `unbekannt-${Date.now()}@example.test`; const fdUnknown = new FormData(); fdUnknown.set("email", unknownEmail); fdUnknown.set("domain", "tenant"); const reqUnknown = await requestPasswordReset({ status: "idle" }, fdUnknown); check("unbekannte Adresse: identische Antwort", JSON.stringify(req) === JSON.stringify(reqUnknown)); check( "keine Mail an die unbekannte Adresse", (await prisma.mailLog.count({ where: { to: unknownEmail } })) === 0, ); check( "Token nur für das existierende Konto (Prinzipal = Identity)", (await prisma.authToken.count({ where: { principalId: identity.id, usedAt: null } })) === 1, ); const resetMail = await prisma.mailLog.findFirst({ where: { to: EMAIL, template: "password_reset" }, }); check("Reset-Mail eingestellt", resetMail != null, resetMail?.status); // Das Rohtoken existiert nur im Link. Für den weiteren Ablauf einen frischen // Link ausstellen — die Neuanforderung entwertet den vorherigen (geprüft in // test-auth-selfservice.ts). const issued = await issueToken({ principalType: "identity", principalId: identity.id, tenantId: tenant.id, type: "password_reset", }); console.log("2) Policy-Verstoß entwertet den Link nicht"); const weak = new FormData(); weak.set("token", issued.raw); weak.set("domain", "tenant"); weak.set("password", "kurz"); weak.set("confirm", "kurz"); check("schwaches Passwort abgelehnt", (await redeemPasswordReset({ status: "idle" }, weak)).status === "error"); check("Link danach noch gültig", (await peekToken(issued.raw, "password_reset")) != null); const mismatch = new FormData(); mismatch.set("token", issued.raw); mismatch.set("domain", "tenant"); mismatch.set("password", NEW_PW); mismatch.set("confirm", `${NEW_PW}x`); check( "abweichende Wiederholung abgelehnt", (await redeemPasswordReset({ status: "idle" }, mismatch)).status === "error", ); check("Link weiterhin gültig", (await peekToken(issued.raw, "password_reset")) != null); console.log("3) Einlösung (Passwort/Session an der Identity)"); const before = await prisma.identity.findUniqueOrThrow({ where: { id: identity.id } }); const fd2 = new FormData(); fd2.set("token", issued.raw); fd2.set("domain", "tenant"); fd2.set("password", NEW_PW); fd2.set("confirm", NEW_PW); const done = await redeemPasswordReset({ status: "idle" }, fd2); check("Einlösung erfolgreich", done.status === "done", JSON.stringify(done)); const after = await prisma.identity.findUniqueOrThrow({ where: { id: identity.id } }); check("Passwort-Hash (Identity) geändert", before.passwordHash !== after.passwordHash); check("neues Passwort gültig", await verifyPassword(after.passwordHash, NEW_PW)); check("altes Passwort ungültig", !(await verifyPassword(after.passwordHash, OLD_PW))); check("Sessions invalidiert (Identity)", after.sessionsValidAfter != null); check("Force-Change zurückgesetzt (Identity)", after.mustChangePassword === false); check("Link verbraucht", (await peekToken(issued.raw, "password_reset")) === null); const secondUse = new FormData(); secondUse.set("token", issued.raw); secondUse.set("domain", "tenant"); secondUse.set("password", NEW_PW); secondUse.set("confirm", NEW_PW); check( "zweite Einlösung abgewiesen", (await redeemPasswordReset({ status: "idle" }, secondUse)).status === "error", ); const confirmMail = await prisma.mailLog.findFirst({ where: { to: EMAIL, template: "password_changed" }, }); check("Bestätigungsmail versendet", confirmMail != null, confirmMail?.status); const audit = await prisma.auditLog.findFirst({ where: { entity: "password_reset", entityId: identity.id }, }); check("Audit-Eintrag vorhanden", audit != null); check( "kein Token im Audit", audit == null || !JSON.stringify(audit).includes(issued.raw.slice(0, 12)), ); check( "kein Token im MailLog", (await prisma.mailLog.count({ where: { dedupeKey: { contains: issued.raw.slice(0, 12) } } })) === 0, ); console.log("4) Deaktivierte Identity"); // Option C: das Konto ist die Identity — Deaktivierung wirkt an ihr, nicht an // der einzelnen Mitgliedschaft. await prisma.identity.update({ where: { id: identity.id }, data: { status: "DISABLED" } }); resetRateLimits(); const openBefore = await prisma.authToken.count({ where: { principalId: identity.id, type: "password_reset", usedAt: null }, }); const fd3 = new FormData(); fd3.set("email", EMAIL); fd3.set("domain", "tenant"); const req3 = await requestPasswordReset({ status: "idle" }, fd3); const openAfter = await prisma.authToken.count({ where: { principalId: identity.id, type: "password_reset", usedAt: null }, }); check("Antwort trotzdem neutral", req3.status === "done"); check("kein neuer Token", openAfter === openBefore); } finally { await prisma.authToken.deleteMany({ where: { principalId: { in: [identity.id, user.id] } } }); await prisma.mailLog.deleteMany({ where: { to: EMAIL } }); await prisma.auditLog.deleteMany({ where: { actorId: { in: [identity.id, user.id] } } }); await prisma.user.delete({ where: { id: user.id } }).catch(() => {}); await prisma.identity.deleteMany({ where: { email: EMAIL } }); } await prisma.$disconnect(); await closeMailProvider(); await closeQueues(); if (failures > 0) { console.error(`\n✗ ${failures} Prüfung(en) fehlgeschlagen.`); process.exit(1); } console.log("\n✓ SEC2 Reset-Ablauf: alle Prüfungen bestanden."); process.exit(0); } main().catch(async (err) => { console.error(err); await prisma.$disconnect().catch(() => {}); process.exit(1); });