test(mfa): full MFA test coverage — unit + E2E
Unit tests (packages/api — 13 tests): - generateTotpSecret: DB write, returns secret + uri - verifyAndEnableTotp: valid token enables; invalid/already-enabled/no-secret guards - verifyTotp (login): valid → ok; invalid → UNAUTHORIZED; not-enabled → BAD_REQUEST - getCurrentMfaStatus: reads totpEnabled flag E2E tests (apps/web/e2e/dev-system/mfa.spec.ts — 7 scenarios): - Setup flow: generate secret, enable with valid code, reject invalid code, UI QR check - Login flow: MFA prompt appears, valid code logs in, wrong code shows error + stays on prompt - Login without MFA: no TOTP prompt for users without MFA enabled Also: start.sh health-check timeout 30s → 90s (container startup can exceed 30s) Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 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
|
||||
* - getCurrentMfaStatus: status read
|
||||
*
|
||||
* otpauth is mocked so tests are deterministic and do not depend on
|
||||
* time-based code generation.
|
||||
*/
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ─── otpauth mock ────────────────────────────────────────────────────────────
|
||||
// Must be hoisted before imports that pull in the module under test.
|
||||
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 };
|
||||
});
|
||||
|
||||
// ─── import after mock setup ─────────────────────────────────────────────────
|
||||
import {
|
||||
generateTotpSecret,
|
||||
verifyAndEnableTotp,
|
||||
verifyTotp,
|
||||
getCurrentMfaStatus,
|
||||
} from "../router/user-self-service-procedure-support.js";
|
||||
|
||||
// ─── context helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function makeSelfServiceCtx(dbOverrides: Record<string, unknown> = {}) {
|
||||
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<string>(),
|
||||
roleDefaults: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makePublicCtx(dbOverrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
db: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
...((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<typeof generateTotpSecret>[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<typeof generateTotpSecret>[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<typeof verifyAndEnableTotp>[0],
|
||||
{ token: "123456" },
|
||||
);
|
||||
expect(result).toEqual({ enabled: true });
|
||||
expect(ctx.db.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user_1" },
|
||||
data: { totpEnabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
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<typeof verifyAndEnableTotp>[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<typeof verifyAndEnableTotp>[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<typeof verifyAndEnableTotp>[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<typeof verifyAndEnableTotp>[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();
|
||||
});
|
||||
|
||||
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<typeof verifyTotp>[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<typeof verifyTotp>[0], { userId: "user_1", token: "000000" }),
|
||||
).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." }));
|
||||
});
|
||||
|
||||
it("throws BAD_REQUEST when user does not have MFA enabled", async () => {
|
||||
const ctx = makePublicCtx({
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ ...mfaUser, totpEnabled: false }) },
|
||||
});
|
||||
await expect(
|
||||
verifyTotp(ctx as Parameters<typeof verifyTotp>[0], { userId: "user_1", token: "123456" }),
|
||||
).rejects.toThrow(new TRPCError({ code: "BAD_REQUEST", message: "TOTP is not enabled for this user." }));
|
||||
});
|
||||
|
||||
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<typeof verifyTotp>[0], { userId: "user_1", token: "123456" }),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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<typeof getCurrentMfaStatus>[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<typeof getCurrentMfaStatus>[0]);
|
||||
expect(result).toEqual({ totpEnabled: false });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user