/** * Unit tests for MFA procedure functions in user-self-service-procedure-support.ts. * * Tests cover: * - generateTotpSecret: secret creation and DB write * - verifyAndEnableTotp: valid/invalid token, guard conditions * - verifyTotp (login): valid/invalid token, not-enabled guard, rate limiting * - getCurrentMfaStatus: status read * * otpauth is mocked so tests are deterministic and do not depend on * time-based code generation. * totpRateLimiter is mocked to allow/block independently of real Redis. */ import { TRPCError } from "@trpc/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; // ─── otpauth mock ──────────────────────────────────────────────────────────── const totpValidateMock = vi.hoisted(() => vi.fn<() => number | null>()); vi.mock("otpauth", () => { class Secret { base32: string; constructor() { this.base32 = "TESTBASE32SECRET"; } static fromBase32(v: string) { return v; } } class TOTP { validate(_args: { token: string; window: number }) { return totpValidateMock(); } toString() { return "otpauth://totp/CapaKraken:test@example.com?secret=TESTBASE32SECRET"; } } return { Secret, TOTP }; }); // ─── rate-limit mock ────────────────────────────────────────────────────────── // Default: rate limit allows all requests. Override in specific tests. const totpRateLimiterMock = vi.hoisted(() => vi.fn(async (_key: string) => ({ allowed: true, remaining: 9, resetAt: new Date(Date.now() + 30_000), })), ); vi.mock("../middleware/rate-limit.js", () => ({ totpRateLimiter: totpRateLimiterMock, apiRateLimiter: vi.fn(async () => ({ allowed: true, remaining: 99, resetAt: new Date() })), authRateLimiter: vi.fn(async () => ({ allowed: true, remaining: 4, resetAt: new Date() })), })); // ─── import after mock setup ───────────────────────────────────────────────── import { generateTotpSecret, verifyAndEnableTotp, verifyTotp, getCurrentMfaStatus, } from "../router/user-self-service-procedure-support.js"; // ─── context helpers ───────────────────────────────────────────────────────── function makeSelfServiceCtx(dbOverrides: Record = {}) { return { db: { user: { findUnique: vi.fn(), update: vi.fn().mockResolvedValue({}), ...((dbOverrides.user as object | undefined) ?? {}), }, auditLog: { create: vi.fn().mockResolvedValue({ id: "audit_1" }), ...((dbOverrides.auditLog as object | undefined) ?? {}), }, }, dbUser: { id: "user_1", systemRole: "ADMIN" as const, permissionOverrides: null }, session: { user: { email: "test@example.com", name: "Test User", image: null }, expires: "2027-01-01T00:00:00.000Z", }, userId: "user_1", userRole: "ADMIN" as const, permissions: new Set(), roleDefaults: null, }; } function makePublicCtx(dbOverrides: Record = {}) { return { db: { user: { findUnique: vi.fn(), update: vi.fn().mockResolvedValue({}), ...((dbOverrides.user as object | undefined) ?? {}), }, }, }; } // ─── generateTotpSecret ─────────────────────────────────────────────────────── describe("generateTotpSecret", () => { beforeEach(() => { vi.clearAllMocks(); }); it("writes the base32 secret to the user record", async () => { const ctx = makeSelfServiceCtx(); await generateTotpSecret(ctx as Parameters[0]); expect(ctx.db.user.update).toHaveBeenCalledWith({ where: { id: "user_1" }, data: { totpSecret: "TESTBASE32SECRET" }, }); }); it("returns the base32 secret and an otpauth URI", async () => { const ctx = makeSelfServiceCtx(); const result = await generateTotpSecret(ctx as Parameters[0]); expect(result.secret).toBe("TESTBASE32SECRET"); expect(result.uri).toMatch(/^otpauth:\/\/totp\//); }); }); // ─── verifyAndEnableTotp ────────────────────────────────────────────────────── describe("verifyAndEnableTotp", () => { beforeEach(() => { vi.clearAllMocks(); totpValidateMock.mockReset(); }); const baseUser = { id: "user_1", name: "Test User", email: "test@example.com", totpSecret: "TESTBASE32SECRET", totpEnabled: false, }; it("enables TOTP and returns { enabled: true } when token is valid", async () => { totpValidateMock.mockReturnValue(0); // delta 0 = current window const ctx = makeSelfServiceCtx({ user: { findUnique: vi.fn().mockResolvedValue(baseUser) }, }); const result = await verifyAndEnableTotp(ctx as Parameters[0], { token: "123456", }); expect(result).toEqual({ enabled: true }); expect(ctx.db.user.update).toHaveBeenCalledWith({ where: { id: "user_1" }, data: { totpEnabled: true, lastTotpAt: expect.any(Date) }, }); }); it("throws BAD_REQUEST when token is invalid", async () => { totpValidateMock.mockReturnValue(null); const ctx = makeSelfServiceCtx({ user: { findUnique: vi.fn().mockResolvedValue(baseUser) }, }); await expect( verifyAndEnableTotp(ctx as Parameters[0], { token: "000000" }), ).rejects.toThrow(new TRPCError({ code: "BAD_REQUEST", message: "Invalid TOTP token." })); }); it("throws BAD_REQUEST when no secret has been generated yet", async () => { const ctx = makeSelfServiceCtx({ user: { findUnique: vi.fn().mockResolvedValue({ ...baseUser, totpSecret: null }) }, }); await expect( verifyAndEnableTotp(ctx as Parameters[0], { token: "123456" }), ).rejects.toThrow(TRPCError); }); it("throws BAD_REQUEST when TOTP is already enabled", async () => { const ctx = makeSelfServiceCtx({ user: { findUnique: vi.fn().mockResolvedValue({ ...baseUser, totpEnabled: true }) }, }); await expect( verifyAndEnableTotp(ctx as Parameters[0], { token: "123456" }), ).rejects.toThrow(new TRPCError({ code: "BAD_REQUEST", message: "TOTP is already enabled." })); }); it("writes an audit entry on successful enable", async () => { totpValidateMock.mockReturnValue(0); const ctx = makeSelfServiceCtx({ user: { findUnique: vi.fn().mockResolvedValue(baseUser) }, }); await verifyAndEnableTotp(ctx as Parameters[0], { token: "123456", }); // Audit entry is fire-and-forget; wait one tick await new Promise((r) => setTimeout(r, 0)); expect(ctx.db.auditLog.create).toHaveBeenCalled(); }); }); // ─── verifyTotp (login step) ────────────────────────────────────────────────── describe("verifyTotp", () => { beforeEach(() => { vi.clearAllMocks(); totpValidateMock.mockReset(); totpRateLimiterMock.mockResolvedValue({ allowed: true, remaining: 9, resetAt: new Date(Date.now() + 30_000), }); }); const mfaUser = { id: "user_1", totpSecret: "TESTBASE32SECRET", totpEnabled: true }; it("returns { valid: true } when token is correct", async () => { totpValidateMock.mockReturnValue(0); const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } }); const result = await verifyTotp(ctx as Parameters[0], { userId: "user_1", token: "123456", }); expect(result).toEqual({ valid: true }); }); it("throws UNAUTHORIZED when token is invalid", async () => { totpValidateMock.mockReturnValue(null); const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } }); await expect( verifyTotp(ctx as Parameters[0], { userId: "user_1", token: "000000" }), ).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." })); }); it("throws UNAUTHORIZED when user does not have MFA enabled (prevents user enumeration)", async () => { const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue({ ...mfaUser, totpEnabled: false }) }, }); await expect( verifyTotp(ctx as Parameters[0], { userId: "user_1", token: "123456" }), ).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." })); }); it("throws BAD_REQUEST when user has no TOTP secret (inconsistent state)", async () => { const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue({ ...mfaUser, totpSecret: null }) }, }); await expect( verifyTotp(ctx as Parameters[0], { userId: "user_1", token: "123456" }), ).rejects.toThrow(TRPCError); }); it("throws TOO_MANY_REQUESTS when rate limit is exceeded", async () => { totpRateLimiterMock.mockResolvedValue({ allowed: false, remaining: 0, resetAt: new Date(Date.now() + 30_000), }); const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } }); await expect( verifyTotp(ctx as Parameters[0], { userId: "user_1", token: "123456" }), ).rejects.toThrow( new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many TOTP attempts. Please wait before trying again.", }), ); }); it("does not check the token when rate limit is exceeded", async () => { totpRateLimiterMock.mockResolvedValue({ allowed: false, remaining: 0, resetAt: new Date() }); const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } }); await expect( verifyTotp(ctx as Parameters[0], { userId: "user_1", token: "123456" }), ).rejects.toThrow(TRPCError); // DB user lookup must not happen when rate-limited expect(ctx.db.user.findUnique).not.toHaveBeenCalled(); }); it("calls the rate limiter with the userId as key", async () => { totpValidateMock.mockReturnValue(0); const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } }); await verifyTotp(ctx as Parameters[0], { userId: "user_1", token: "123456", }); expect(totpRateLimiterMock).toHaveBeenCalledWith("user_1"); }); }); // ─── getCurrentMfaStatus ────────────────────────────────────────────────────── describe("getCurrentMfaStatus", () => { beforeEach(() => { vi.clearAllMocks(); }); it("returns totpEnabled: true when MFA is active", async () => { const ctx = makeSelfServiceCtx({ user: { findUnique: vi.fn().mockResolvedValue({ totpEnabled: true }) }, }); const result = await getCurrentMfaStatus(ctx as Parameters[0]); expect(result).toEqual({ totpEnabled: true }); }); it("returns totpEnabled: false when MFA is inactive", async () => { const ctx = makeSelfServiceCtx({ user: { findUnique: vi.fn().mockResolvedValue({ totpEnabled: false }) }, }); const result = await getCurrentMfaStatus(ctx as Parameters[0]); expect(result).toEqual({ totpEnabled: false }); }); });