// @vitest-environment node import { describe, expect, test, vi, beforeEach } from "vitest"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; const mockUpdate = vi.fn(); const mockUpdateMany = vi.fn(); const mockFindUnique = vi.fn(); vi.mock("../db.server", () => ({ db: { user: { update: mockUpdate, updateMany: mockUpdateMany, findUnique: mockFindUnique, }, }, })); vi.mock("../cache.server", () => ({ cachified: vi.fn(), clearCacheKey: vi.fn() })); vi.mock("~/modules/crm/services/CrmService", () => ({ default: { createContact: vi.fn() } })); const testDir = path.dirname(fileURLToPath(import.meta.url)); const usersSource = readFileSync(path.join(testDir, "users.db.server.ts"), "utf8"); const schemaSource = readFileSync(path.join(testDir, "../../../../../prisma/schema/accounts.prisma"), "utf8"); const migrationSource = readFileSync(path.join(testDir, "../../../../../prisma/migrations/20260508130000_user_totp_mfa/migration.sql"), "utf8"); const recoveryCodesMigrationSource = readFileSync( path.join(testDir, "../../../../../prisma/migrations/20260508133000_user_mfa_recovery_codes/migration.sql"), "utf8" ); describe("user TOTP MFA storage contract", () => { beforeEach(() => { process.env.CRYPTO_SECRET = "test-recovery-secret"; mockUpdate.mockReset(); mockUpdateMany.mockReset(); mockFindUnique.mockReset(); }); test("schema and migration define nullable encrypted TOTP state without defaults", () => { expect(schemaSource).toContain("totpSecretEncrypted String?"); expect(schemaSource).toContain("totpPendingSecretEncrypted String?"); expect(schemaSource).toContain("totpEnabledAt DateTime?"); expect(schemaSource).toContain("totpPendingCreatedAt DateTime?"); for (const column of ["totpSecretEncrypted", "totpPendingSecretEncrypted", "totpEnabledAt", "totpPendingCreatedAt"]) { expect(migrationSource).toContain(`ADD COLUMN "${column}"`); } expect(migrationSource).not.toMatch(/NOT NULL|DEFAULT|otpauth|secret_fixture|totp-secret/i); }); test("schema and migration define nullable hashed recovery-code state without plaintext fixtures", () => { expect(schemaSource).toContain("mfaRecoveryCodeHashes Json?"); expect(schemaSource).toContain("mfaRecoveryCodesGeneratedAt DateTime?"); expect(schemaSource).toContain("mfaRecoveryCodeLastUsedAt DateTime?"); expect(schemaSource).not.toMatch(/recoveryCodesPlaintext|recoveryCodePlaintext|mfaRecoveryCodePlain|mfaRecoveryCodesPlain/i); for (const column of ["mfaRecoveryCodeHashes", "mfaRecoveryCodesGeneratedAt", "mfaRecoveryCodeLastUsedAt"]) { expect(recoveryCodesMigrationSource).toContain(`ADD COLUMN "${column}"`); } expect(recoveryCodesMigrationSource).not.toMatch(/NOT NULL|DEFAULT|\b[A-Z0-9]{4}-[A-Z0-9]{4}\b|plaintext|plain text|secret_fixture|recovery-code-secret/i); }); test("generic getUser select omits encrypted TOTP fields and recovery-code hashes", () => { const getUserBlock = usersSource.match(/export async function getUser\(userId\?: string\)[\s\S]*?export async function setUserTotpPendingSecret/); expect(getUserBlock?.[0]).toContain("select: {"); expect(getUserBlock?.[0]).not.toContain("totpSecretEncrypted"); expect(getUserBlock?.[0]).not.toContain("totpPendingSecretEncrypted"); expect(getUserBlock?.[0]).not.toContain("mfaRecoveryCodeHashes"); }); test("setUserTotpPendingSecret stores pending secret metadata and clears cache", async () => { const { setUserTotpPendingSecret } = await import("./users.db.server"); const { clearCacheKey } = await import("../cache.server"); const pendingCreatedAt = new Date("2026-05-08T13:00:00.000Z"); mockUpdate.mockResolvedValueOnce({ id: "user_1" }); await setUserTotpPendingSecret("user_1", "encrypted-pending-secret", pendingCreatedAt); expect(mockUpdate).toHaveBeenCalledWith({ where: { id: "user_1" }, data: { totpPendingSecretEncrypted: "encrypted-pending-secret", totpPendingCreatedAt: pendingCreatedAt, }, }); expect(clearCacheKey).toHaveBeenCalledWith("user:user_1"); }); test("activateUserTotpPendingSecret promotes pending secret, clears pending fields, and clears cache", async () => { const { activateUserTotpPendingSecret } = await import("./users.db.server"); const { clearCacheKey } = await import("../cache.server"); const enabledAt = new Date("2026-05-08T13:05:00.000Z"); mockUpdate.mockResolvedValueOnce({ id: "user_1" }); await activateUserTotpPendingSecret("user_1", "encrypted-active-secret", enabledAt); expect(mockUpdate).toHaveBeenCalledWith({ where: { id: "user_1" }, data: { totpSecretEncrypted: "encrypted-active-secret", totpEnabledAt: enabledAt, totpPendingSecretEncrypted: null, totpPendingCreatedAt: null, }, }); expect(clearCacheKey).toHaveBeenCalledWith("user:user_1"); }); test("disableUserTotp clears active and pending TOTP state and clears cache", async () => { const { disableUserTotp } = await import("./users.db.server"); const { clearCacheKey } = await import("../cache.server"); mockUpdate.mockResolvedValueOnce({ id: "user_1" }); await disableUserTotp("user_1"); expect(mockUpdate).toHaveBeenCalledWith({ where: { id: "user_1" }, data: { totpSecretEncrypted: null, totpPendingSecretEncrypted: null, totpEnabledAt: null, totpPendingCreatedAt: null, }, }); expect(clearCacheKey).toHaveBeenCalledWith("user:user_1"); }); test("getUserTotpStatus exposes booleans and timestamps without encrypted secrets", async () => { const { getUserTotpStatus } = await import("./users.db.server"); const enabledAt = new Date("2026-05-08T13:05:00.000Z"); const pendingCreatedAt = new Date("2026-05-08T13:00:00.000Z"); mockFindUnique.mockResolvedValueOnce({ totpSecretEncrypted: "encrypted-active-secret", totpPendingSecretEncrypted: "encrypted-pending-secret", totpEnabledAt: enabledAt, totpPendingCreatedAt: pendingCreatedAt, }); const status = await getUserTotpStatus("user_1"); expect(mockFindUnique).toHaveBeenCalledWith({ where: { id: "user_1" }, select: { totpSecretEncrypted: true, totpPendingSecretEncrypted: true, totpEnabledAt: true, totpPendingCreatedAt: true, }, }); expect(status).toEqual({ enabled: true, pending: true, enabledAt, pendingCreatedAt, }); expect(status).not.toHaveProperty("totpSecretEncrypted"); expect(status).not.toHaveProperty("totpPendingSecretEncrypted"); }); test("setUserMfaRecoveryCodes replaces hashes, resets last-used timestamp, and clears cache", async () => { const { setUserMfaRecoveryCodes } = await import("./users.db.server"); const { clearCacheKey } = await import("../cache.server"); const generatedAt = new Date("2026-05-08T14:00:00.000Z"); const entries = [{ hash: "hash-1", usedAt: null, createdAt: generatedAt }]; mockUpdate.mockResolvedValueOnce({ id: "user_1" }); await setUserMfaRecoveryCodes("user_1", entries, generatedAt); expect(mockUpdate).toHaveBeenCalledWith({ where: { id: "user_1" }, data: { mfaRecoveryCodeHashes: entries, mfaRecoveryCodesGeneratedAt: generatedAt, mfaRecoveryCodeLastUsedAt: null, }, }); expect(clearCacheKey).toHaveBeenCalledWith("user:user_1"); expect(await setUserMfaRecoveryCodes(undefined, entries, generatedAt)).toBeNull(); }); test("getUserMfaRecoveryCodeStatus returns redacted status only", async () => { const { getUserMfaRecoveryCodeStatus } = await import("./users.db.server"); const generatedAt = new Date("2026-05-08T14:00:00.000Z"); const lastUsedAt = new Date("2026-05-08T14:05:00.000Z"); mockFindUnique.mockResolvedValueOnce({ mfaRecoveryCodeHashes: [ { hash: "hash-1", usedAt: null, createdAt: generatedAt }, { hash: "hash-2", usedAt: lastUsedAt, createdAt: generatedAt }, ], mfaRecoveryCodesGeneratedAt: generatedAt, mfaRecoveryCodeLastUsedAt: lastUsedAt, }); const status = await getUserMfaRecoveryCodeStatus("user_1"); expect(mockFindUnique).toHaveBeenCalledWith({ where: { id: "user_1" }, select: { mfaRecoveryCodeHashes: true, mfaRecoveryCodesGeneratedAt: true, mfaRecoveryCodeLastUsedAt: true, }, }); expect(status).toEqual({ configured: true, remainingCount: 1, generatedAt, lastUsedAt }); expect(JSON.stringify(status)).not.toMatch(/hash-1|hash-2|recovery-code/i); expect(await getUserMfaRecoveryCodeStatus(undefined)).toBeNull(); }); test("consumeUserMfaRecoveryCode marks one matching unused code and clears cache", async () => { const { consumeUserMfaRecoveryCode } = await import("./users.db.server"); const { hashRecoveryCode } = await import("~/utils/auth/mfa/recovery-codes.server"); const { clearCacheKey } = await import("../cache.server"); const generatedAt = new Date("2026-05-08T14:00:00.000Z"); const usedAt = new Date("2026-05-08T14:10:00.000Z"); const entries = [ { hash: hashRecoveryCode("ABCD-EFGH-JKLM")!, usedAt: null, createdAt: generatedAt }, { hash: hashRecoveryCode("NPQR-STUV-WXYZ")!, usedAt: null, createdAt: generatedAt }, ]; mockFindUnique.mockResolvedValueOnce({ mfaRecoveryCodeHashes: entries, mfaRecoveryCodesGeneratedAt: generatedAt, mfaRecoveryCodeLastUsedAt: null, }); mockUpdateMany.mockResolvedValueOnce({ count: 1 }); const result = await consumeUserMfaRecoveryCode("user_1", "ABCD-EFGH-JKLM", { now: usedAt }); expect(mockUpdateMany).toHaveBeenCalledWith({ where: { id: "user_1", mfaRecoveryCodeHashes: { equals: entries } }, data: { mfaRecoveryCodeHashes: [{ ...entries[0], usedAt }, entries[1]], mfaRecoveryCodeLastUsedAt: usedAt, }, }); expect(result).toEqual({ consumed: true, remainingCount: 1, usedAt }); expect(clearCacheKey).toHaveBeenCalledWith("user:user_1"); }); test("consumeUserMfaRecoveryCode rejects missing state, wrong code, and used-code replay without update", async () => { const { consumeUserMfaRecoveryCode } = await import("./users.db.server"); const { hashRecoveryCode } = await import("~/utils/auth/mfa/recovery-codes.server"); const generatedAt = new Date("2026-05-08T14:00:00.000Z"); const usedAt = new Date("2026-05-08T14:10:00.000Z"); const usedEntries = [{ hash: hashRecoveryCode("ABCD-EFGH-JKLM")!, usedAt, createdAt: generatedAt }]; mockFindUnique.mockResolvedValueOnce(null); await expect(consumeUserMfaRecoveryCode("user_1", "ABCD-EFGH-JKLM")).resolves.toEqual({ consumed: false, remainingCount: 0, usedAt: null, }); mockFindUnique.mockResolvedValueOnce({ mfaRecoveryCodeHashes: usedEntries, mfaRecoveryCodesGeneratedAt: generatedAt, mfaRecoveryCodeLastUsedAt: usedAt, }); await expect(consumeUserMfaRecoveryCode("user_1", "WRONG-CODE-HERE")).resolves.toEqual({ consumed: false, remainingCount: 0, usedAt: null, }); mockFindUnique.mockResolvedValueOnce({ mfaRecoveryCodeHashes: usedEntries, mfaRecoveryCodesGeneratedAt: generatedAt, mfaRecoveryCodeLastUsedAt: usedAt, }); await expect(consumeUserMfaRecoveryCode("user_1", "ABCD-EFGH-JKLM")).resolves.toEqual({ consumed: false, remainingCount: 0, usedAt: null, }); await expect(consumeUserMfaRecoveryCode(undefined, "ABCD-EFGH-JKLM")).resolves.toBeNull(); expect(mockUpdateMany).not.toHaveBeenCalled(); }); });