security: MFA backup codes — issue on enable, redeem at login, regenerate on demand (#43)
CI / Architecture Guardrails (push) Successful in 6m1s
CI / Assistant Split Regression (push) Successful in 6m52s
CI / Lint (push) Successful in 8m40s
CI / Typecheck (push) Successful in 9m45s
CI / Unit Tests (push) Successful in 7m28s
CI / Build (push) Failing after 10m16s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Architecture Guardrails (push) Successful in 6m1s
CI / Assistant Split Regression (push) Successful in 6m52s
CI / Lint (push) Successful in 8m40s
CI / Typecheck (push) Successful in 9m45s
CI / Unit Tests (push) Successful in 7m28s
CI / Build (push) Failing after 10m16s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
Adds a one-time-use backup code set so users with a lost authenticator are not locked out. Codes are Crockford base32 (XXXXX-XXXXX), hashed with argon2id, and redeemed under a WHERE-guarded delete so a concurrent replay race fails closed. - New MfaBackupCode model + migration - Issue 10 codes inside the enable transaction; show plaintext exactly once - Sign-in page accepts TOTP or backup code, reporting remaining count - regenerateBackupCodes tRPC mutation wipes + reissues atomically - Unit coverage for generator, normalizer, verify, redeem, and race path Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -41,7 +41,7 @@ describe("assistant user self-service MFA tools - enable flow", () => {
|
||||
it("enables TOTP through the real user router path when the token is valid", async () => {
|
||||
totpValidateMock.mockReturnValue(0);
|
||||
|
||||
const db = {
|
||||
const db: Record<string, unknown> = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "user_1",
|
||||
@@ -56,6 +56,11 @@ describe("assistant user self-service MFA tools - enable flow", () => {
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({ id: "audit_1" }),
|
||||
},
|
||||
mfaBackupCode: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 10 }),
|
||||
},
|
||||
$transaction: vi.fn().mockImplementation(async (ops: unknown[]) => ops.map(() => ({}))),
|
||||
};
|
||||
const ctx = createToolContext(db, SystemRole.ADMIN);
|
||||
|
||||
@@ -99,11 +104,14 @@ describe("assistant user self-service MFA tools - enable flow", () => {
|
||||
summary: "Enabled TOTP MFA",
|
||||
}),
|
||||
});
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
success: true,
|
||||
enabled: true,
|
||||
message: "Enabled MFA TOTP.",
|
||||
});
|
||||
const parsed = JSON.parse(result.content);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.enabled).toBe(true);
|
||||
expect(parsed.message).toBe("Enabled MFA TOTP.");
|
||||
expect(parsed.backupCodes).toHaveLength(10);
|
||||
for (const code of parsed.backupCodes) {
|
||||
expect(code).toMatch(/^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/);
|
||||
}
|
||||
expect(result.action).toEqual({
|
||||
type: "invalidate",
|
||||
scope: ["user"],
|
||||
|
||||
@@ -19,6 +19,9 @@ describe("assistant user self-service MFA tools - status", () => {
|
||||
totpEnabled: true,
|
||||
}),
|
||||
},
|
||||
mfaBackupCode: {
|
||||
count: vi.fn().mockResolvedValue(3),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db, SystemRole.ADMIN);
|
||||
|
||||
@@ -30,6 +33,7 @@ describe("assistant user self-service MFA tools - status", () => {
|
||||
});
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
totpEnabled: true,
|
||||
backupCodesRemaining: 3,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +43,9 @@ describe("assistant user self-service MFA tools - status", () => {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
mfaBackupCode: {
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
},
|
||||
SystemRole.ADMIN,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 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 });
|
||||
});
|
||||
});
|
||||
@@ -40,13 +40,15 @@ describe("user-procedure-support", () => {
|
||||
});
|
||||
|
||||
it("lists assignable users with the expected lightweight selection", async () => {
|
||||
const findMany = vi.fn().mockResolvedValue([
|
||||
{ id: "user_1", name: "Alice", email: "alice@example.com" },
|
||||
]);
|
||||
const findMany = vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: "user_1", name: "Alice", email: "alice@example.com" }]);
|
||||
|
||||
const result = await listAssignableUsers(createContext({
|
||||
user: { findMany },
|
||||
}));
|
||||
const result = await listAssignableUsers(
|
||||
createContext({
|
||||
user: { findMany },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual([{ id: "user_1", name: "Alice", email: "alice@example.com" }]);
|
||||
expect(findMany).toHaveBeenCalledWith({
|
||||
@@ -56,12 +58,16 @@ describe("user-procedure-support", () => {
|
||||
});
|
||||
|
||||
it("counts only users active within the trailing five minute window", async () => {
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(new Date("2026-03-30T20:00:00.000Z").valueOf());
|
||||
const nowSpy = vi
|
||||
.spyOn(Date, "now")
|
||||
.mockReturnValue(new Date("2026-03-30T20:00:00.000Z").valueOf());
|
||||
const count = vi.fn().mockResolvedValue(4);
|
||||
|
||||
const result = await countActiveUsers(createContext({
|
||||
user: { count },
|
||||
}));
|
||||
const result = await countActiveUsers(
|
||||
createContext({
|
||||
user: { count },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ count: 4 });
|
||||
expect(count).toHaveBeenCalledWith({
|
||||
@@ -80,9 +86,11 @@ describe("user-procedure-support", () => {
|
||||
createdAt: new Date("2026-03-30T08:00:00.000Z"),
|
||||
});
|
||||
|
||||
const result = await getCurrentUserProfile(createContext({
|
||||
user: { findUnique },
|
||||
}));
|
||||
const result = await getCurrentUserProfile(
|
||||
createContext({
|
||||
user: { findUnique },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "user_admin",
|
||||
@@ -108,17 +116,21 @@ describe("user-procedure-support", () => {
|
||||
it("unlinks an existing resource before linking the requested one", async () => {
|
||||
const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" });
|
||||
const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", userId: null });
|
||||
const updateMany = vi.fn()
|
||||
const updateMany = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ count: 1 })
|
||||
.mockResolvedValueOnce({ count: 1 });
|
||||
|
||||
const result = await linkUserResource(createContext({
|
||||
user: { findUnique: userFindUnique },
|
||||
resource: { findUnique: resourceFindUnique, updateMany },
|
||||
}), {
|
||||
userId: "user_1",
|
||||
resourceId: "resource_1",
|
||||
});
|
||||
const result = await linkUserResource(
|
||||
createContext({
|
||||
user: { findUnique: userFindUnique },
|
||||
resource: { findUnique: resourceFindUnique, updateMany },
|
||||
}),
|
||||
{
|
||||
userId: "user_1",
|
||||
resourceId: "resource_1",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(updateMany).toHaveBeenNthCalledWith(1, {
|
||||
@@ -142,9 +154,11 @@ describe("user-procedure-support", () => {
|
||||
updatedAt: new Date("2026-03-30T18:00:00.000Z"),
|
||||
});
|
||||
|
||||
const result = await getDashboardLayout(createContext({
|
||||
user: { findUnique },
|
||||
}));
|
||||
const result = await getDashboardLayout(
|
||||
createContext({
|
||||
user: { findUnique },
|
||||
}),
|
||||
);
|
||||
|
||||
// Widgets with unknown types normalise to empty → return null so client uses default
|
||||
expect(result).toEqual({
|
||||
@@ -159,11 +173,14 @@ describe("user-procedure-support", () => {
|
||||
});
|
||||
const update = vi.fn().mockResolvedValue({});
|
||||
|
||||
const result = await toggleFavoriteProject(createContext({
|
||||
user: { findUnique, update },
|
||||
}), {
|
||||
projectId: "project_2",
|
||||
});
|
||||
const result = await toggleFavoriteProject(
|
||||
createContext({
|
||||
user: { findUnique, update },
|
||||
}),
|
||||
{
|
||||
projectId: "project_2",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
favoriteProjectIds: ["project_1", "project_2"],
|
||||
@@ -187,12 +204,15 @@ describe("user-procedure-support", () => {
|
||||
});
|
||||
const update = vi.fn().mockResolvedValue({ id: "user_admin" });
|
||||
|
||||
const result = await setColumnPreferences(createContext({
|
||||
user: { findUnique, update },
|
||||
}), {
|
||||
view: "resources",
|
||||
visible: ["name", "email"],
|
||||
});
|
||||
const result = await setColumnPreferences(
|
||||
createContext({
|
||||
user: { findUnique, update },
|
||||
}),
|
||||
{
|
||||
view: "resources",
|
||||
visible: ["name", "email"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
@@ -220,11 +240,14 @@ describe("user-procedure-support", () => {
|
||||
permissionOverrides: overrides,
|
||||
});
|
||||
|
||||
const result = await getEffectiveUserPermissions(createContext({
|
||||
user: { findUnique },
|
||||
}), {
|
||||
userId: "user_2",
|
||||
});
|
||||
const result = await getEffectiveUserPermissions(
|
||||
createContext({
|
||||
user: { findUnique },
|
||||
}),
|
||||
{
|
||||
userId: "user_2",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
systemRole: SystemRole.MANAGER,
|
||||
@@ -234,14 +257,20 @@ describe("user-procedure-support", () => {
|
||||
});
|
||||
|
||||
it("reports MFA status for the current user and throws when the user no longer exists", async () => {
|
||||
const findUnique = vi.fn()
|
||||
const findUnique = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ totpEnabled: true })
|
||||
.mockResolvedValueOnce(null);
|
||||
const count = vi.fn().mockResolvedValue(7);
|
||||
const ctx = createContext({
|
||||
user: { findUnique },
|
||||
mfaBackupCode: { count },
|
||||
});
|
||||
|
||||
await expect(getCurrentMfaStatus(ctx)).resolves.toEqual({ totpEnabled: true });
|
||||
await expect(getCurrentMfaStatus(ctx)).resolves.toEqual({
|
||||
totpEnabled: true,
|
||||
backupCodesRemaining: 7,
|
||||
});
|
||||
await expect(getCurrentMfaStatus(ctx)).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
|
||||
@@ -55,6 +55,12 @@ function createAdminCaller(db: Record<string, unknown>) {
|
||||
// Individual tests can override by passing their own `activeSession` key.
|
||||
const dbWithDefaults = {
|
||||
activeSession: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) },
|
||||
mfaBackupCode: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 10 }),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
$transaction: vi.fn(async (ops: unknown[]) => ops),
|
||||
...db,
|
||||
};
|
||||
return createCaller({
|
||||
@@ -735,7 +741,8 @@ describe("user profile and TOTP self-service", () => {
|
||||
|
||||
const result = await caller.verifyAndEnableTotp({ token: "123456" });
|
||||
|
||||
expect(result).toEqual({ enabled: true });
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.backupCodes).toHaveLength(10);
|
||||
// lastTotpAt is written atomically by updateMany (the replay guard);
|
||||
// user.update only toggles the enabled flag after the CAS succeeds.
|
||||
expect(updateMany).toHaveBeenCalledWith(
|
||||
@@ -1035,11 +1042,16 @@ describe("user column preferences and MFA status", () => {
|
||||
user: {
|
||||
findUnique,
|
||||
},
|
||||
mfaBackupCode: {
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
count: vi.fn().mockResolvedValue(4),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.getMfaStatus();
|
||||
|
||||
expect(result).toEqual({ totpEnabled: true });
|
||||
expect(result).toEqual({ totpEnabled: true, backupCodesRemaining: 4 });
|
||||
expect(findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "user_admin" },
|
||||
select: { totpEnabled: true },
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
verifyAndEnableTotp,
|
||||
verifyTotp,
|
||||
getCurrentMfaStatus,
|
||||
regenerateBackupCodes,
|
||||
} from "../router/user-self-service-procedure-support.js";
|
||||
|
||||
// ─── context helpers ─────────────────────────────────────────────────────────
|
||||
@@ -74,10 +75,17 @@ function makeSelfServiceCtx(dbOverrides: Record<string, unknown> = {}) {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
...((dbOverrides.user as object | undefined) ?? {}),
|
||||
},
|
||||
mfaBackupCode: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 10 }),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
...((dbOverrides.mfaBackupCode as object | undefined) ?? {}),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({ id: "audit_1" }),
|
||||
...((dbOverrides.auditLog as object | undefined) ?? {}),
|
||||
},
|
||||
$transaction: vi.fn(async (ops: unknown[]) => ops),
|
||||
},
|
||||
dbUser: { id: "user_1", systemRole: "ADMIN" as const, permissionOverrides: null },
|
||||
session: {
|
||||
@@ -145,7 +153,7 @@ describe("verifyAndEnableTotp", () => {
|
||||
totpEnabled: false,
|
||||
};
|
||||
|
||||
it("enables TOTP and returns { enabled: true } when token is valid", async () => {
|
||||
it("enables TOTP and returns backup codes when token is valid", async () => {
|
||||
totpValidateMock.mockReturnValue(0); // delta 0 = current window
|
||||
const ctx = makeSelfServiceCtx({
|
||||
user: { findUnique: vi.fn().mockResolvedValue(baseUser) },
|
||||
@@ -153,7 +161,12 @@ describe("verifyAndEnableTotp", () => {
|
||||
const result = await verifyAndEnableTotp(ctx as Parameters<typeof verifyAndEnableTotp>[0], {
|
||||
token: "123456",
|
||||
});
|
||||
expect(result).toEqual({ enabled: true });
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.backupCodes).toHaveLength(10);
|
||||
// Codes have the XXXXX-XXXXX shape (10 Crockford-base32 chars + one dash)
|
||||
for (const code of result.backupCodes) {
|
||||
expect(code).toMatch(/^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/);
|
||||
}
|
||||
expect(ctx.db.user.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { lastTotpAt: expect.any(Date) } }),
|
||||
);
|
||||
@@ -161,6 +174,17 @@ describe("verifyAndEnableTotp", () => {
|
||||
where: { id: "user_1" },
|
||||
data: { totpEnabled: true },
|
||||
});
|
||||
// Exactly 10 backup code rows are created in a transaction
|
||||
expect(ctx.db.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.db.mfaBackupCode.deleteMany).toHaveBeenCalledWith({ where: { userId: "user_1" } });
|
||||
const createCall = ctx.db.mfaBackupCode.createMany.mock.calls[0]![0] as {
|
||||
data: Array<{ userId: string; codeHash: string }>;
|
||||
};
|
||||
expect(createCall.data).toHaveLength(10);
|
||||
for (const row of createCall.data) {
|
||||
expect(row.userId).toBe("user_1");
|
||||
expect(row.codeHash.length).toBeGreaterThan(50); // argon2id encoded form
|
||||
}
|
||||
});
|
||||
|
||||
it("throws BAD_REQUEST when token is invalid", async () => {
|
||||
@@ -314,19 +338,87 @@ describe("getCurrentMfaStatus", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns totpEnabled: true when MFA is active", async () => {
|
||||
it("returns totpEnabled and backupCodesRemaining when MFA is active", async () => {
|
||||
const ctx = makeSelfServiceCtx({
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ totpEnabled: true }) },
|
||||
mfaBackupCode: {
|
||||
count: vi.fn().mockResolvedValue(7),
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
});
|
||||
const result = await getCurrentMfaStatus(ctx as Parameters<typeof getCurrentMfaStatus>[0]);
|
||||
expect(result).toEqual({ totpEnabled: true });
|
||||
expect(result).toEqual({ totpEnabled: true, backupCodesRemaining: 7 });
|
||||
});
|
||||
|
||||
it("returns totpEnabled: false when MFA is inactive", async () => {
|
||||
it("returns backupCodesRemaining: 0 when MFA is inactive (skips DB count)", async () => {
|
||||
const countMock = vi.fn();
|
||||
const ctx = makeSelfServiceCtx({
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ totpEnabled: false }) },
|
||||
mfaBackupCode: { count: countMock, deleteMany: vi.fn(), createMany: vi.fn() },
|
||||
});
|
||||
const result = await getCurrentMfaStatus(ctx as Parameters<typeof getCurrentMfaStatus>[0]);
|
||||
expect(result).toEqual({ totpEnabled: false });
|
||||
expect(result).toEqual({ totpEnabled: false, backupCodesRemaining: 0 });
|
||||
expect(countMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── regenerateBackupCodes ────────────────────────────────────────────────────
|
||||
|
||||
describe("regenerateBackupCodes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("throws BAD_REQUEST when TOTP is not enabled", async () => {
|
||||
const ctx = makeSelfServiceCtx({
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "user_1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
totpEnabled: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
regenerateBackupCodes(ctx as Parameters<typeof regenerateBackupCodes>[0]),
|
||||
).rejects.toThrow(TRPCError);
|
||||
expect(ctx.db.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wipes previous codes and issues a fresh set atomically", async () => {
|
||||
const ctx = makeSelfServiceCtx({
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "user_1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
totpEnabled: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const result = await regenerateBackupCodes(ctx as Parameters<typeof regenerateBackupCodes>[0]);
|
||||
expect(result.count).toBe(10);
|
||||
expect(result.codes).toHaveLength(10);
|
||||
expect(new Set(result.codes).size).toBe(10); // all distinct
|
||||
expect(ctx.db.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.db.mfaBackupCode.deleteMany).toHaveBeenCalledWith({ where: { userId: "user_1" } });
|
||||
});
|
||||
|
||||
it("writes an audit entry on regeneration", async () => {
|
||||
const ctx = makeSelfServiceCtx({
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "user_1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
totpEnabled: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await regenerateBackupCodes(ctx as Parameters<typeof regenerateBackupCodes>[0]);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(ctx.db.auditLog.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user