Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/auth/mfa/recovery-codes.server.test.ts
T

159 lines
7.1 KiB
TypeScript

// @vitest-environment node
import { beforeEach, describe, expect, test } from "vitest";
const codePattern = /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}-[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}-[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}$/;
describe("recovery-codes.server", () => {
beforeEach(() => {
process.env.CRYPTO_SECRET = "test-recovery-code-secret";
});
test("generates the default count of unique human-readable codes with hash-only storage entries", async () => {
const { DEFAULT_RECOVERY_CODE_COUNT, generateRecoveryCodes } = await import("./recovery-codes.server");
const generatedAt = new Date("2026-05-08T14:00:00.000Z");
const result = generateRecoveryCodes({ now: generatedAt });
expect(result.codes).toHaveLength(DEFAULT_RECOVERY_CODE_COUNT);
expect(new Set(result.codes).size).toBe(DEFAULT_RECOVERY_CODE_COUNT);
expect(result.codes).toEqual(Array.from({ length: DEFAULT_RECOVERY_CODE_COUNT }, () => expect.stringMatching(codePattern)));
expect(result.generatedAt).toBe(generatedAt);
expect(result.entries).toHaveLength(DEFAULT_RECOVERY_CODE_COUNT);
for (const [index, entry] of result.entries.entries()) {
expect(entry).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/), usedAt: null, createdAt: generatedAt });
expect(entry.hash).not.toBe(result.codes[index]);
expect(JSON.stringify(entry)).not.toContain(result.codes[index]);
}
});
test("normalizes code input and verifies hashes with constant-length safe comparison", async () => {
const { hashRecoveryCode, normalizeRecoveryCode, verifyRecoveryCodeHash } = await import("./recovery-codes.server");
const code = "2345-6789-ABCD";
const hash = hashRecoveryCode(code);
expect(normalizeRecoveryCode(" 2345 6789 abcd ")).toBe(code);
expect(hash).toMatch(/^[a-f0-9]{64}$/);
expect(verifyRecoveryCodeHash("23456789abcd", hash!)).toBe(true);
expect(verifyRecoveryCodeHash("2345-6789-ABCE", hash!)).toBe(false);
expect(verifyRecoveryCodeHash("2345-6789-ABCD", "abcd")).toBe(false);
});
test("rejects malformed input before hashing or verification", async () => {
const { hashRecoveryCode, normalizeRecoveryCode, verifyRecoveryCodeHash } = await import("./recovery-codes.server");
expect(normalizeRecoveryCode("")).toBeNull();
expect(normalizeRecoveryCode("1234-5678-ABCD")).toBeNull();
expect(normalizeRecoveryCode("2345-6789-ABC")).toBeNull();
expect(normalizeRecoveryCode("2345-6789-ABCI")).toBeNull();
expect(hashRecoveryCode("2345-6789-ABCI")).toBeNull();
expect(verifyRecoveryCodeHash("2345-6789-ABCI", "00".repeat(32))).toBe(false);
});
test("consumes a valid recovery code once and rejects replay", async () => {
const { consumeRecoveryCode, generateRecoveryCodes } = await import("./recovery-codes.server");
const generated = generateRecoveryCodes({ count: 2, now: new Date("2026-05-08T14:00:00.000Z") });
const firstUseAt = new Date("2026-05-08T14:05:00.000Z");
const replayAt = new Date("2026-05-08T14:06:00.000Z");
const firstUse = consumeRecoveryCode(generated.entries, generated.codes[0], { now: firstUseAt });
const replay = consumeRecoveryCode(firstUse.entries, generated.codes[0], { now: replayAt });
const wrongCode = consumeRecoveryCode(firstUse.entries, "2345-6789-ABCD", { now: replayAt });
expect(firstUse.matched).toBe(true);
expect(firstUse.usedAt).toBe(firstUseAt);
expect(firstUse.entries[0].usedAt).toBe(firstUseAt);
expect(firstUse.entries[1].usedAt).toBeNull();
expect(replay).toEqual({ matched: false, entries: firstUse.entries, usedAt: null });
expect(wrongCode).toEqual({ matched: false, entries: firstUse.entries, usedAt: null });
});
test("generation does not loop indefinitely: succeeds on the first attempt under healthy entropy", async () => {
const { generateRecoveryCodes, DEFAULT_RECOVERY_CODE_COUNT } = await import("./recovery-codes.server");
const result = generateRecoveryCodes();
expect(result.codes).toHaveLength(DEFAULT_RECOVERY_CODE_COUNT);
expect(new Set(result.codes).size).toBe(DEFAULT_RECOVERY_CODE_COUNT);
});
test("generation completes in bounded time even when asked for the maximum count (no rejection-sampling loop)", async () => {
const { generateRecoveryCodes } = await import("./recovery-codes.server");
// The legacy implementation rebuilt a `Set` with the codes and looped
// until uniqueness — pathological under bad entropy. We assert a strict
// wall-clock bound to catch any regression that re-introduces unbounded
// looping.
const start = Date.now();
const result = generateRecoveryCodes({ count: 50 });
const elapsed = Date.now() - start;
expect(result.codes).toHaveLength(50);
expect(new Set(result.codes).size).toBe(50);
expect(elapsed).toBeLessThan(500);
});
test("status helpers redact raw codes and hashes when serialized", async () => {
const { consumeRecoveryCode, generateRecoveryCodes, getRecoveryCodeStatus } = await import("./recovery-codes.server");
const generatedAt = new Date("2026-05-08T14:00:00.000Z");
const usedAt = new Date("2026-05-08T14:10:00.000Z");
const generated = generateRecoveryCodes({ count: 3, now: generatedAt });
const consumed = consumeRecoveryCode(generated.entries, generated.codes[1], { now: usedAt });
const status = getRecoveryCodeStatus(consumed.entries);
const serialized = JSON.stringify(status);
expect(status).toEqual({
configured: true,
totalCount: 3,
remainingCount: 2,
usedCount: 1,
generatedAt,
lastUsedAt: usedAt,
});
for (const code of generated.codes) {
expect(serialized).not.toContain(code);
}
for (const entry of generated.entries) {
expect(serialized).not.toContain(entry.hash);
}
expect(serialized).not.toMatch(/hash|code|secret/i);
});
test("status helper reports an unconfigured state for null or undefined entries", async () => {
const { getRecoveryCodeStatus } = await import("./recovery-codes.server");
expect(getRecoveryCodeStatus(null)).toEqual({
configured: false,
totalCount: 0,
remainingCount: 0,
usedCount: 0,
generatedAt: null,
lastUsedAt: null,
});
expect(getRecoveryCodeStatus(undefined, { generatedAt: null, lastUsedAt: null })).toEqual({
configured: false,
totalCount: 0,
remainingCount: 0,
usedCount: 0,
generatedAt: null,
lastUsedAt: null,
});
});
test("consumeRecoveryCode rejects malformed codes and uses the current time when no now option is supplied", async () => {
const { consumeRecoveryCode, generateRecoveryCodes } = await import("./recovery-codes.server");
const generated = generateRecoveryCodes({ count: 1, now: new Date("2026-05-08T14:00:00.000Z") });
expect(consumeRecoveryCode(generated.entries, "bad-code")).toEqual({
matched: false,
entries: generated.entries,
usedAt: null,
});
const consumed = consumeRecoveryCode(generated.entries, generated.codes[0]);
expect(consumed.matched).toBe(true);
expect(consumed.usedAt).toBeInstanceOf(Date);
expect(consumed.entries[0].usedAt).toBe(consumed.usedAt);
});
});