/** * Unit tests for the MFA backup-code generator, canonicalisation, and the * atomic redemption helper. Together they cover the three guarantees that * make backup codes safe: * * 1. High-entropy, distinct plaintexts (generator). * 2. Canonical form is what gets hashed/compared — a user can paste the * code with or without the dash, upper or lower case. * 3. Redemption deletes the row under a WHERE-guard so a concurrent * second redemption fails (replay race). */ import { describe, expect, it, vi } from "vitest"; import { BACKUP_CODE_COUNT, generatePlaintextBackupCodes, hashBackupCode, normalizeBackupCode, verifyBackupCode, } from "../lib/mfa-backup-codes.js"; import { redeemBackupCode } from "../lib/mfa-backup-code-redeem.js"; describe("generatePlaintextBackupCodes", () => { it("yields BACKUP_CODE_COUNT distinct codes by default", () => { const codes = generatePlaintextBackupCodes(); expect(codes).toHaveLength(BACKUP_CODE_COUNT); expect(new Set(codes).size).toBe(BACKUP_CODE_COUNT); }); it("formats each code as five chars, dash, five chars from the Crockford alphabet", () => { for (const code of generatePlaintextBackupCodes(20)) { expect(code).toMatch(/^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/); } }); }); describe("normalizeBackupCode", () => { it("strips dashes and whitespace and uppercases", () => { expect(normalizeBackupCode("ab12c-xy34z")).toBe("AB12CXY34Z"); expect(normalizeBackupCode(" AB12C XY34Z ")).toBe("AB12CXY34Z"); expect(normalizeBackupCode("ab12cxy34z")).toBe("AB12CXY34Z"); }); }); describe("verifyBackupCode", () => { it("accepts the plaintext (with or without dash) that produced the hash", async () => { const hash = await hashBackupCode("ABCDE-FGHJK"); expect(await verifyBackupCode(hash, "ABCDE-FGHJK")).toBe(true); expect(await verifyBackupCode(hash, "abcde-fghjk")).toBe(true); expect(await verifyBackupCode(hash, "ABCDEFGHJK")).toBe(true); }); it("rejects a different plaintext", async () => { const hash = await hashBackupCode("ABCDE-FGHJK"); expect(await verifyBackupCode(hash, "ZZZZZ-ZZZZZ")).toBe(false); }); it("returns false rather than throwing on a malformed hash", async () => { expect(await verifyBackupCode("not-a-real-hash", "anything")).toBe(false); }); }); describe("redeemBackupCode", () => { it("accepts a valid code, deletes the row, and reports remaining count", async () => { const goodHash = await hashBackupCode("GOOD1-CODE1"); const otherHash = await hashBackupCode("OTHER-CODE2"); const db = { mfaBackupCode: { findMany: vi.fn().mockResolvedValue([ { id: "a", codeHash: otherHash }, { id: "b", codeHash: goodHash }, ]), deleteMany: vi.fn().mockResolvedValue({ count: 1 }), count: vi.fn().mockResolvedValue(1), }, }; const result = await redeemBackupCode(db, "user_1", "GOOD1-CODE1"); expect(result).toEqual({ accepted: true, remaining: 1 }); expect(db.mfaBackupCode.deleteMany).toHaveBeenCalledWith({ where: { id: "b", usedAt: null }, }); }); it("rejects an unknown code without deleting anything", async () => { const db = { mfaBackupCode: { findMany: vi .fn() .mockResolvedValue([{ id: "a", codeHash: await hashBackupCode("REAL1-CODE1") }]), deleteMany: vi.fn(), count: vi.fn().mockResolvedValue(1), }, }; const result = await redeemBackupCode(db, "user_1", "WRONG-CODE"); expect(result.accepted).toBe(false); expect(result.remaining).toBe(1); expect(db.mfaBackupCode.deleteMany).not.toHaveBeenCalled(); }); it("treats a racing delete (count=0) as an invalid code", async () => { // Simulates the case where another login request redeemed this exact // code a millisecond earlier. The SQL WHERE-guard (usedAt: null) stops // us from deleting it twice — we must treat that as a failed attempt // so the attacker cannot learn the code was valid. const goodHash = await hashBackupCode("RACE1-CODE1"); const db = { mfaBackupCode: { findMany: vi.fn().mockResolvedValue([{ id: "a", codeHash: goodHash }]), deleteMany: vi.fn().mockResolvedValue({ count: 0 }), count: vi.fn().mockResolvedValue(0), }, }; const result = await redeemBackupCode(db, "user_1", "RACE1-CODE1"); expect(result.accepted).toBe(false); }); it("returns accepted:false / remaining:0 when the user has no codes", async () => { const db = { mfaBackupCode: { findMany: vi.fn().mockResolvedValue([]), deleteMany: vi.fn(), count: vi.fn().mockResolvedValue(0), }, }; const result = await redeemBackupCode(db, "user_1", "ANY-CODE"); expect(result).toEqual({ accepted: false, remaining: 0 }); }); });