import { afterAll, beforeAll, describe, expect, it } from "vitest"; import request from "supertest"; import type { FastifyInstance } from "fastify"; import { PrismaClient } from "@prisma/client"; import argon2 from "argon2"; import { createHash } from "node:crypto"; import { buildApp } from "../src/server"; const prisma = new PrismaClient(); let app: FastifyInstance; const hashToken = (token: string) => createHash("sha256").update(token).digest("hex"); beforeAll(async () => { app = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: false, PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: 20, PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: 20, }); await app.ready(); }); afterAll(async () => { if (app) await app.close(); await prisma.$disconnect(); }); describe("Forgot password security", () => { it("returns generic success and only issues reset token for verified users", async () => { const verifiedEmail = `fp-verified-${Date.now()}@test.dev`; const unverifiedEmail = `fp-unverified-${Date.now()}@test.dev`; const passwordHash = await argon2.hash("SupersAFE123!"); const [verifiedUser, unverifiedUser] = await Promise.all([ prisma.user.create({ data: { email: verifiedEmail, passwordHash, emailVerified: true }, }), prisma.user.create({ data: { email: unverifiedEmail, passwordHash, emailVerified: false }, }), ]); const unknownRes = await request(app.server) .post("/auth/forgot-password/request") .send({ email: `missing-${Date.now()}@test.dev` }); const verifiedRes = await request(app.server) .post("/auth/forgot-password/request") .send({ email: verifiedEmail }); const unverifiedRes = await request(app.server) .post("/auth/forgot-password/request") .send({ email: unverifiedEmail }); expect(unknownRes.status).toBe(200); expect(verifiedRes.status).toBe(200); expect(unverifiedRes.status).toBe(200); expect(unknownRes.body).toEqual({ ok: true, message: "If an account exists, reset instructions were sent.", }); expect(verifiedRes.body).toEqual(unknownRes.body); expect(unverifiedRes.body).toEqual(unknownRes.body); const [verifiedTokens, unverifiedTokens] = await Promise.all([ prisma.emailToken.count({ where: { userId: verifiedUser.id, type: "password_reset", usedAt: null }, }), prisma.emailToken.count({ where: { userId: unverifiedUser.id, type: "password_reset", usedAt: null }, }), ]); expect(verifiedTokens).toBe(1); expect(unverifiedTokens).toBe(0); await prisma.user.deleteMany({ where: { email: { in: [verifiedEmail, unverifiedEmail] } }, }); }); it("consumes reset token once, updates password, and invalidates prior session", async () => { const email = `fp-confirm-${Date.now()}@test.dev`; const oldPassword = "SupersAFE123!"; const newPassword = "EvenStrong3rPass!"; const rawToken = "reset-token-value-0123456789abcdef"; const user = await prisma.user.create({ data: { email, emailVerified: true, passwordHash: await argon2.hash(oldPassword), }, }); const login = await request(app.server).post("/auth/login").send({ email, password: oldPassword }); expect(login.status).toBe(200); const sessionCookie = (login.headers["set-cookie"] ?? []).find((c: string) => c.startsWith("session=")); expect(sessionCookie).toBeTruthy(); await prisma.emailToken.create({ data: { userId: user.id, type: "password_reset", tokenHash: hashToken(rawToken), expiresAt: new Date(Date.now() + 30 * 60_000), }, }); const confirm = await request(app.server) .post("/auth/forgot-password/confirm") .send({ uid: user.id, token: rawToken, newPassword }); expect(confirm.status).toBe(200); expect(confirm.body.ok).toBe(true); const tokenRecord = await prisma.emailToken.findFirst({ where: { userId: user.id, type: "password_reset", tokenHash: hashToken(rawToken) }, }); expect(tokenRecord?.usedAt).toBeTruthy(); const secondUse = await request(app.server) .post("/auth/forgot-password/confirm") .send({ uid: user.id, token: rawToken, newPassword: "AnotherStrongPass4!" }); expect(secondUse.status).toBe(400); expect(secondUse.body.code).toBe("INVALID_OR_EXPIRED_RESET_LINK"); const oldSessionAccess = await request(app.server) .get("/dashboard") .set("Cookie", [sessionCookie as string]); expect(oldSessionAccess.status).toBe(401); const oldPasswordLogin = await request(app.server) .post("/auth/login") .send({ email, password: oldPassword }); expect(oldPasswordLogin.status).toBe(401); const newPasswordLogin = await request(app.server) .post("/auth/login") .send({ email, password: newPassword }); expect(newPasswordLogin.status).toBe(200); await prisma.user.deleteMany({ where: { id: user.id } }); }); it("rejects uid/token mismatches with generic error", async () => { const emailA = `fp-a-${Date.now()}@test.dev`; const emailB = `fp-b-${Date.now()}@test.dev`; const rawToken = "mismatch-token-value-abcdef0123456789"; const [userA, userB] = await Promise.all([ prisma.user.create({ data: { email: emailA, emailVerified: true, passwordHash: await argon2.hash("SupersAFE123!") }, }), prisma.user.create({ data: { email: emailB, emailVerified: true, passwordHash: await argon2.hash("SupersAFE123!") }, }), ]); await prisma.emailToken.create({ data: { userId: userA.id, type: "password_reset", tokenHash: hashToken(rawToken), expiresAt: new Date(Date.now() + 30 * 60_000), }, }); const mismatch = await request(app.server) .post("/auth/forgot-password/confirm") .send({ uid: userB.id, token: rawToken, newPassword: "MismatchPass9!" }); expect(mismatch.status).toBe(400); expect(mismatch.body.code).toBe("INVALID_OR_EXPIRED_RESET_LINK"); await prisma.user.deleteMany({ where: { id: { in: [userA.id, userB.id] } } }); }); });