/** * Unit tests for the MFA error classes exported from auth.ts. * * These tests are a regression guard: they verify that the three * CredentialsSignin subclasses carry the correct `code` value so that * Auth.js v5 forwards them to the client via SignInResponse.code instead of * wrapping them as a generic CallbackRouteError. * * Testing the full authorize() call graph requires mocking the entire Next.js * runtime and is covered by E2E tests instead. */ import { beforeEach, describe, expect, it, vi } from "vitest"; // ── next-auth imports next/server without .js extension which fails in vitest // node env. Mock the whole module so the error classes can be imported. // Capture the config passed to NextAuth() so callbacks can be invoked. const nextAuthCalls: Array<{ callbacks?: { jwt?: (...args: unknown[]) => unknown; session?: (...args: unknown[]) => unknown; }; }> = []; vi.mock("next-auth", () => { class CredentialsSignin extends Error { code = "credentials"; } return { default: vi.fn( (cfg: { callbacks?: { jwt?: (...args: unknown[]) => unknown; session?: (...args: unknown[]) => unknown; }; }) => { nextAuthCalls.push(cfg); return { handlers: {}, auth: vi.fn() }; }, ), CredentialsSignin, }; }); // ── All other side-effectful imports auth.ts pulls in ─────────────────────── vi.mock("./runtime-env.js", () => ({ assertSecureRuntimeEnv: vi.fn() })); // Capture the config passed to Credentials() so we can call authorize(). const credentialsCalls: Array<{ authorize: (...args: unknown[]) => unknown }> = []; vi.mock("next-auth/providers/credentials", () => ({ default: vi.fn((cfg: { authorize: (...args: unknown[]) => unknown }) => { credentialsCalls.push(cfg); return cfg; }), })); const prismaMock = { user: { findUnique: vi.fn(), update: vi.fn() }, systemSettings: { findUnique: vi.fn() }, activeSession: { create: vi.fn(), findMany: vi.fn(), deleteMany: vi.fn(), delete: vi.fn() }, }; vi.mock("@capakraken/db", () => ({ prisma: prismaMock })); vi.mock("@capakraken/api/middleware/rate-limit", () => ({ authRateLimiter: vi.fn().mockResolvedValue({ allowed: true }), })); vi.mock("@capakraken/api/lib/audit", () => ({ createAuditEntry: vi.fn() })); vi.mock("@capakraken/api/lib/logger", () => ({ logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, })); const argonVerifyMock = vi.fn(); vi.mock("@node-rs/argon2", () => ({ verify: argonVerifyMock })); // ── Import the exported error classes after mocks are in place ─────────────── const { MfaRequiredError, MfaRequiredSetupError, InvalidTotpError } = await import("./auth.js"); describe("MFA CredentialsSignin error classes — code property", () => { it("MfaRequiredError.code is 'MFA_REQUIRED'", () => { expect(new MfaRequiredError().code).toBe("MFA_REQUIRED"); }); it("MfaRequiredSetupError.code is 'MFA_REQUIRED_SETUP'", () => { expect(new MfaRequiredSetupError().code).toBe("MFA_REQUIRED_SETUP"); }); it("InvalidTotpError.code is 'INVALID_TOTP'", () => { expect(new InvalidTotpError().code).toBe("INVALID_TOTP"); }); it("all three extend the mocked CredentialsSignin base class", async () => { const { CredentialsSignin } = await import("next-auth"); expect(new MfaRequiredError()).toBeInstanceOf(CredentialsSignin); expect(new MfaRequiredSetupError()).toBeInstanceOf(CredentialsSignin); expect(new InvalidTotpError()).toBeInstanceOf(CredentialsSignin); }); it("error class names are descriptive (not generic 'Error')", () => { expect(new MfaRequiredError().constructor.name).toBe("MfaRequiredError"); expect(new MfaRequiredSetupError().constructor.name).toBe("MfaRequiredSetupError"); expect(new InvalidTotpError().constructor.name).toBe("InvalidTotpError"); }); }); describe("session() — does not leak JTI to client", () => { const sessionCb = nextAuthCalls[0]?.callbacks?.session; if (!sessionCb) { it.skip("session callback not captured", () => {}); return; } it("never assigns token.sid onto session.user.jti", async () => { const session = await sessionCb({ session: { user: { email: "x@e.com" }, expires: "2030-01-01" }, token: { sub: "u1", role: "USER", sid: "secret-session-id" }, }); const user = (session as { user: Record }).user; expect(user["jti"]).toBeUndefined(); expect(user["sid"]).toBeUndefined(); expect(user["id"]).toBe("u1"); expect(user["role"]).toBe("USER"); }); }); describe("jwt() — concurrent-session enforcement is fail-closed", () => { const jwtCb = nextAuthCalls[0]?.callbacks?.jwt; if (!jwtCb) { it.skip("jwt callback not captured", () => {}); return; } beforeEach(() => { prismaMock.systemSettings.findUnique.mockReset(); prismaMock.activeSession.create.mockReset(); prismaMock.activeSession.findMany.mockReset(); prismaMock.activeSession.deleteMany.mockReset(); }); it("throws if activeSession.create fails", async () => { prismaMock.systemSettings.findUnique.mockResolvedValue({ maxConcurrentSessions: 3 }); prismaMock.activeSession.create.mockRejectedValue(new Error("db down")); await expect(jwtCb({ token: {}, user: { id: "u1", role: "USER" } })).rejects.toThrow( /Session registration failed/, ); }); it("returns the token when session-registry writes succeed", async () => { prismaMock.systemSettings.findUnique.mockResolvedValue({ maxConcurrentSessions: 3 }); prismaMock.activeSession.create.mockResolvedValue({}); prismaMock.activeSession.findMany.mockResolvedValue([]); const result = (await jwtCb({ token: {}, user: { id: "u1", role: "USER" } })) as Record< string, unknown >; expect(result["role"]).toBe("USER"); expect(typeof result["sid"]).toBe("string"); }); }); describe("authorize() — login timing / enumeration defence", () => { const authorize = credentialsCalls[0]?.authorize; if (!authorize) { it.skip("authorize was not captured", () => {}); return; } beforeEach(() => { argonVerifyMock.mockReset(); prismaMock.user.findUnique.mockReset(); prismaMock.user.update.mockReset(); prismaMock.systemSettings.findUnique.mockReset(); }); it("runs argon2.verify against a dummy hash when the user is not found", async () => { prismaMock.user.findUnique.mockResolvedValue(null); argonVerifyMock.mockResolvedValue(false); const result = await authorize( { email: "nobody@example.com", password: "s3cret-password" }, undefined, ); expect(result).toBeNull(); expect(argonVerifyMock).toHaveBeenCalledTimes(1); const [hashArg, passwordArg] = argonVerifyMock.mock.calls[0]!; expect(typeof hashArg).toBe("string"); expect(hashArg).toMatch(/^\$argon2id\$/); expect(passwordArg).toBe("s3cret-password"); }); it("runs argon2.verify against a dummy hash when the account is deactivated", async () => { prismaMock.user.findUnique.mockResolvedValue({ id: "u1", email: "x@example.com", isActive: false, passwordHash: "$argon2id$real$hash", }); argonVerifyMock.mockResolvedValue(false); const result = await authorize({ email: "x@example.com", password: "wrong" }, undefined); expect(result).toBeNull(); expect(argonVerifyMock).toHaveBeenCalledTimes(1); expect(argonVerifyMock.mock.calls[0]![0]).toMatch(/^\$argon2id\$/); }); it("records a uniform 'Login failed' audit summary for every failure branch", async () => { const { createAuditEntry } = await import("@capakraken/api/lib/audit"); const auditMock = createAuditEntry as unknown as ReturnType; auditMock.mockClear(); // Branch 1: user not found prismaMock.user.findUnique.mockResolvedValueOnce(null); argonVerifyMock.mockResolvedValueOnce(false); await authorize({ email: "a@example.com", password: "p" }, undefined); // Branch 2: deactivated account prismaMock.user.findUnique.mockResolvedValueOnce({ id: "u1", email: "b@example.com", isActive: false, passwordHash: "$argon2id$h", }); argonVerifyMock.mockResolvedValueOnce(false); await authorize({ email: "b@example.com", password: "p" }, undefined); // Branch 3: wrong password prismaMock.user.findUnique.mockResolvedValueOnce({ id: "u2", email: "c@example.com", isActive: true, passwordHash: "$argon2id$h", }); argonVerifyMock.mockResolvedValueOnce(false); await authorize({ email: "c@example.com", password: "p" }, undefined); const summaries = auditMock.mock.calls.map( (call: unknown[]) => (call[0] as { summary: string }).summary, ); expect(summaries).toEqual(["Login failed", "Login failed", "Login failed"]); }); });