/* eslint-disable @typescript-eslint/no-require-imports -- Test the actual services/actions with request and transport boundaries replaced. */ const { test, before, beforeEach, after } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const vm = require("node:vm"); const ts = require("typescript"); const { randomUUID } = require("node:crypto"); const { PrismaClient } = require("@prisma/client"); const { hash, compare } = require("bcryptjs"); const { render } = require("@react-email/render"); let prisma, current, f, service, actions, tokenHash, initialHash, jwt; const tenants = [], emails = [], background = []; let transportFailure = false; const oldPassword = "Test-password-old-2026"; const newPassword = "Test-password-new-2026"; function loader(overrides = {}) { const cache = new Map(); function load(file) { file = path.resolve(file); if (cache.has(file)) return cache.get(file).exports; const mod = { exports: {} }; cache.set(file, mod); const code = ts.transpileModule(fs.readFileSync(file, "utf8"), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true, }, fileName: file, }).outputText; const req = (spec) => { if (Object.hasOwn(overrides, spec)) return overrides[spec]; if (spec === "server-only") return {}; if (spec === "@/lib/prisma") return { prisma }; if (spec === "next/headers") return { headers: async () => new Headers() }; if (spec === "next/cache") return { revalidatePath() {} }; if (spec === "next/navigation") return { redirect(url) { throw new Error(`REDIRECT:${url}`); }, notFound() { throw new Error("NOT_FOUND"); }, }; if (spec.startsWith("@/") || spec.startsWith(".")) { const target = spec.startsWith("@/") ? path.resolve("src", spec.slice(2)) : path.resolve(path.dirname(file), spec); for (const suffix of [".ts", ".tsx", "/index.ts"]) if (fs.existsSync(target + suffix)) return load(target + suffix); } return require(spec); }; vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { filename: file, })(req, mod, mod.exports); return mod.exports; } return load; } const form = (data) => { const f = new FormData(); for (const [key, value] of Object.entries(data)) f.set(key, value); return f; }; const token = () => new URL(emails.at(-1).react.props.resetLink).pathname.split("/").pop(); const resetInput = (link = token(), password = newPassword) => ({ token: link, password, confirmation: password, }); const drain = async () => { while (background.length) await background.shift()(); }; before(async () => { const raw = process.env.TEST_DATABASE_URL; assert.ok( raw, "TEST_DATABASE_URL muss ausdrücklich auf die lokale kita_account_test zeigen.", ); const url = new URL(raw); assert.ok(["127.0.0.1", "localhost"].includes(url.hostname)); assert.equal(url.pathname, "/kita_account_test"); prisma = new PrismaClient({ datasourceUrl: raw }); initialHash = await hash(oldPassword, 12); process.env.AUTH_URL = "http://127.0.0.1:3014"; const load = loader({ "@/lib/auth-utils": { requireSession: async () => ({ user: current }) }, "@/lib/mail": { sendAppEmail: async (input) => { emails.push(input); return transportFailure ? { success: false, error: "test-only" } : { success: true }; }, }, "next/server": { after: (fn) => background.push(fn) }, "next-auth": (config) => { jwt = config.callbacks.jwt; return {}; }, "next-auth/providers/credentials": (options) => options, }); service = load("src/lib/password-management.ts"); actions = load("src/app/passwort-vergessen/actions.ts"); tokenHash = load("src/lib/tokens.ts").hashToken; load("src/auth.ts"); }); beforeEach(async () => { const kita = await prisma.kita.create({ data: { name: "Kontotest", slug: "account-" + randomUUID() }, }); tenants.push(kita.id); const user = await prisma.user.create({ data: { kitaId: kita.id, email: randomUUID() + "@example.invalid", firstName: "Test", lastName: "Konto", passwordHash: initialHash, emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date(), }, }); f = { kita, user }; current = user; emails.length = 0; background.length = 0; transportFailure = false; }); after(async () => { if (!prisma) return; const users = await prisma.user.findMany({ where: { kitaId: { in: tenants } }, select: { id: true }, }); for (const u of users) await prisma.verificationToken.deleteMany({ where: { OR: [ { identifier: u.id }, { identifier: { startsWith: "password-reset:" + Buffer.from(u.id).toString("base64url") + ":", }, }, ], }, }); await prisma.kita.deleteMany({ where: { id: { in: tenants } } }); await prisma.$disconnect(); }); test("Reset speichert nur Hash, gültige App-Adresse und 30-Minuten-Frist; GET verbraucht den Link nicht", async () => { const before = Date.now(); await service.requestPasswordReset(f.user.email); const link = token(), rows = await prisma.verificationToken.findMany({ where: { token: tokenHash(link) }, }); assert.equal(rows.length, 1); assert.notEqual(rows[0].token, link); assert.ok(rows[0].identifier.startsWith("password-reset:")); assert.ok(rows[0].expires.getTime() >= before + 29 * 60_000); assert.ok(rows[0].expires.getTime() <= Date.now() + 30 * 60_000); assert.equal(await service.isPasswordResetValid(link), true); assert.equal(await service.isPasswordResetValid(link), true); assert.match( emails[0].react.props.resetLink, /^http:\/\/127\.0\.0\.1:3014\/neues-passwort\//, ); const html = await render(emails[0].react); assert.match(html, /30 Minuten/); assert.match(html, /ignoriere|ignorieren/); }); test("Anfrage antwortet gleich für bekannte, unbekannte und deaktivierte Adresse; Verarbeitung folgt nach der Antwort", async () => { const known = await actions.requestResetAction( {}, form({ email: f.user.email }), ); assert.equal(emails.length, 0); assert.equal(background.length, 1); await drain(); assert.equal(emails.length, 1); const missing = await actions.requestResetAction( {}, form({ email: randomUUID() + "@example.invalid" }), ); await drain(); await prisma.user.update({ where: { id: f.user.id }, data: { disabledAt: new Date() }, }); const disabled = await actions.requestResetAction( {}, form({ email: f.user.email }), ); await drain(); assert.deepEqual(known, missing); assert.deepEqual(known, disabled); assert.equal(emails.length, 1); }); test("Anfrage normalisiert E-Mail und lässt offene Einladungen unverändert", async () => { await service.requestPasswordReset(" " + f.user.email.toUpperCase() + " "); assert.equal(emails.length, 1); await prisma.user.update({ where: { id: f.user.id }, data: { emailVerifiedAt: null, passwordHash: "" }, }); emails.length = 0; await prisma.verificationToken.create({ data: { identifier: f.user.id, token: "invitation-" + randomUUID(), expires: new Date(Date.now() + 60_000), }, }); await service.requestPasswordReset(f.user.email); assert.equal(emails.length, 0); assert.equal( await prisma.verificationToken.count({ where: { identifier: f.user.id } }), 1, ); }); test("Neue Anfrage ersetzt nur frühere Reset-Links und nicht Aktivierungstokens", async () => { await prisma.verificationToken.create({ data: { identifier: f.user.id, token: "invite-" + randomUUID(), expires: new Date(Date.now() + 60_000), }, }); await service.requestPasswordReset(f.user.email); const first = token(); await service.requestPasswordReset(f.user.email); assert.equal(await service.isPasswordResetValid(first), false); assert.equal(await service.isPasswordResetValid(token()), true); assert.equal( await prisma.verificationToken.count({ where: { identifier: f.user.id } }), 1, ); }); test("Passwort wird gehasht, Lockout aufgehoben und alle alten Anmeldungen werden ungültig", async () => { await service.requestPasswordReset(f.user.email); await prisma.user.update({ where: { id: f.user.id }, data: { lockedUntil: new Date(Date.now() + 60_000), failedLoginAttempts: 10, }, }); await service.resetPassword(resetInput()); const fresh = await prisma.user.findUniqueOrThrow({ where: { id: f.user.id }, }); assert.equal(await compare(newPassword, fresh.passwordHash), true); assert.equal(await compare(oldPassword, fresh.passwordHash), false); assert.equal(fresh.sessionVersion, 1); assert.equal(fresh.failedLoginAttempts, 0); assert.equal(fresh.lockedUntil, null); assert.equal( await jwt({ token: { id: f.user.id, sessionVersion: 0, authenticatedAt: Date.now() }, }), null, ); }); test("Reset-Link ist nur einmal nutzbar", async () => { await service.requestPasswordReset(f.user.email); const payload = resetInput(); await service.resetPassword(payload); assert.equal(await service.isPasswordResetValid(payload.token), false); await assert.rejects(service.resetPassword(payload), /Link ist ungültig/); }); test("Parallele Verwendung desselben Links ändert das Passwort genau einmal", async () => { await service.requestPasswordReset(f.user.email); const payload = resetInput(); const results = await Promise.allSettled([ service.resetPassword(payload), service.resetPassword({ ...payload, password: "Another-test-password", confirmation: "Another-test-password", }), ]); assert.equal(results.filter((r) => r.status === "fulfilled").length, 1); assert.equal( (await prisma.user.findUniqueOrThrow({ where: { id: f.user.id } })) .sessionVersion, 1, ); }); test("Abgelaufene, erfundene und zweckfremde Aktivierungslinks funktionieren nicht", async () => { await service.requestPasswordReset(f.user.email); const link = token(); await prisma.verificationToken.update({ where: { token: tokenHash(link) }, data: { expires: new Date(0) }, }); await assert.rejects( service.resetPassword(resetInput(link)), /Link ist ungültig/, ); const fake = "a".repeat(64); await assert.rejects( service.resetPassword(resetInput(fake)), /Link ist ungültig/, ); await prisma.verificationToken.create({ data: { identifier: f.user.id, token: tokenHash(fake), expires: new Date(Date.now() + 60_000), }, }); await assert.rejects( service.resetPassword(resetInput(fake)), /Link ist ungültig/, ); }); test("Deaktivierung und Kontolöschung verhindern die Verwendung eines vorhandenen Links", async () => { await service.requestPasswordReset(f.user.email); const link = token(); await prisma.user.update({ where: { id: f.user.id }, data: { disabledAt: new Date(), sessionVersion: { increment: 1 } }, }); await assert.rejects( service.resetPassword(resetInput(link)), /Link ist ungültig/, ); await prisma.user.delete({ where: { id: f.user.id } }); assert.equal(await service.isPasswordResetValid(link), false); }); test("Ein Reset-Link für die alte E-Mail-Adresse bleibt auch nach neuer Aktivierung ungültig", async () => { await service.requestPasswordReset(f.user.email); const link = token(); await prisma.user.update({ where: { id: f.user.id }, data: { email: randomUUID() + "@example.invalid", sessionVersion: { increment: 1 }, emailVerifiedAt: null, passwordHash: "", }, }); await prisma.user.update({ where: { id: f.user.id }, data: { emailVerifiedAt: new Date(), passwordHash: initialHash }, }); assert.equal(await service.isPasswordResetValid(link), false); await assert.rejects( service.resetPassword(resetInput(link)), /Link ist ungültig/, ); }); test("Mailfehler hinterlässt keinen nutzbaren Link und verrät keine Kontodaten in der öffentlichen Antwort", async () => { transportFailure = true; const response = await actions.requestResetAction( {}, form({ email: f.user.email }), ); await drain(); assert.equal(response.message, service.passwordResetMessage); assert.equal(await service.isPasswordResetValid(token()), false); assert.equal( (await prisma.user.findUniqueOrThrow({ where: { id: f.user.id } })) .passwordHash, initialHash, ); }); test("Anfragelimit greift ohne abweichende öffentliche Antwort", async () => { const responses = []; for (let i = 0; i < 4; i++) { responses.push( await actions.requestResetAction({}, form({ email: f.user.email })), ); await drain(); } assert.equal(emails.length, 3); for (const response of responses) assert.deepEqual(response, responses[0]); }); test("Passwortbestätigung und UTF-8-Grenze werden geprüft, bevor ein gültiger Link verbraucht wird", async () => { await service.requestPasswordReset(f.user.email); const link = token(); await assert.rejects( service.resetPassword({ ...resetInput(link), confirmation: "different" }), /stimmen nicht überein/, ); await assert.rejects( service.resetPassword(resetInput(link, "😀".repeat(19))), /72 UTF-8/, ); assert.equal(await service.isPasswordResetValid(link), true); }); test("Eigene Passwortänderung verlangt aktuelles Passwort und kann kein fremdes Konto verändern", async () => { const other = await prisma.user.create({ data: { kitaId: f.kita.id, email: randomUUID() + "@example.invalid", firstName: "Andere", lastName: "Person", passwordHash: initialHash, emailVerifiedAt: new Date(), }, }); await assert.rejects( service.changeOwnPassword(current, { currentPassword: "wrong", password: newPassword, confirmation: newPassword, }), /aktuelle Passwort/, ); assert.equal( (await prisma.user.findUnique({ where: { id: f.user.id } })).sessionVersion, 0, ); await service.changeOwnPassword(current, { userId: other.id, currentPassword: oldPassword, password: newPassword, confirmation: newPassword, }); assert.equal( (await prisma.user.findUnique({ where: { id: other.id } })).passwordHash, initialHash, ); assert.equal( await compare( newPassword, (await prisma.user.findUnique({ where: { id: f.user.id } })).passwordHash, ), true, ); }); test("Aktuelles Passwort wiederverwenden und veraltete Sitzung werden zurückgewiesen", async () => { await assert.rejects( service.changeOwnPassword(current, { currentPassword: oldPassword, password: oldPassword, confirmation: oldPassword, }), /anderes neues Passwort/, ); await assert.rejects( service.changeOwnPassword( { ...current, sessionVersion: 4 }, { currentPassword: oldPassword, password: newPassword, confirmation: newPassword, }, ), /aktuelle Passwort/, ); }); test("Profil-Aktion ändert Passwort und widerruft ausstehende Reset-Links", async () => { await service.requestPasswordReset(f.user.email); const link = token(); const result = await actions.changePasswordAction( {}, form({ currentPassword: oldPassword, password: newPassword, confirmation: newPassword, }), ); assert.equal(result.success, true); assert.equal(await service.isPasswordResetValid(link), false); assert.equal( await jwt({ token: { id: f.user.id, sessionVersion: 0, authenticatedAt: Date.now() }, }), null, ); }); test("Anonyme Reset-Aktion benötigt keinen Login, validiert das Token und gibt keine internen Daten zurück", async () => { await service.requestPasswordReset(f.user.email); assert.equal( (await actions.finishResetAction({}, form(resetInput()))).success, true, ); assert.deepEqual( Object.keys( await actions.finishResetAction( {}, form({ ...resetInput(), token: "invalid" }), ), ), ["error"], ); }); test("Ein fehlgeschlagener älterer Versand entfernt keinen inzwischen neu angeforderten Link", async () => { let oldToken; await service.requestPasswordReset(f.user.email, async (input) => { oldToken = new URL(input.react.props.resetLink).pathname.split("/").pop(); await service.requestPasswordReset(f.user.email); return { success: false, error: "test-only" }; }); assert.equal(await service.isPasswordResetValid(oldToken), false); assert.equal(await service.isPasswordResetValid(token()), true); }); test("Wiederholte falsche aktuelle Passwörter werden im Profil begrenzt", async () => { for (let i = 0; i < 5; i++) { assert.match( ( await actions.changePasswordAction( {}, form({ currentPassword: "wrong", password: newPassword, confirmation: newPassword, }), ) ).error, /aktuelle Passwort/, ); } assert.match( ( await actions.changePasswordAction( {}, form({ currentPassword: oldPassword, password: newPassword, confirmation: newPassword, }), ) ).error, /Zu viele Versuche/, ); assert.equal( (await prisma.user.findUniqueOrThrow({ where: { id: f.user.id } })) .sessionVersion, 0, ); }); test("Fehlende oder unzulässige App-Adresse verschickt keinen nutzbaren Reset-Link", async () => { const keys = ["AUTH_URL", "NEXTAUTH_URL", "NEXT_PUBLIC_SITE_URL", "NODE_ENV"]; const old = Object.fromEntries(keys.map((key) => [key, process.env[key]])); try { process.env.NODE_ENV = "production"; delete process.env.AUTH_URL; delete process.env.NEXTAUTH_URL; delete process.env.NEXT_PUBLIC_SITE_URL; await service.requestPasswordReset(f.user.email); process.env.NEXT_PUBLIC_SITE_URL = "javascript:invalid"; await service.requestPasswordReset(f.user.email); assert.equal(emails.length, 0); assert.equal( await prisma.verificationToken.count({ where: { identifier: { startsWith: "password-reset:" + Buffer.from(f.user.id).toString("base64url") + ":", }, }, }), 0, ); } finally { for (const key of keys) { if (old[key] === undefined) delete process.env[key]; else process.env[key] = old[key]; } } });