Files
CapaKraken/packages/api/src/__tests__/user-self-service-mfa.test.ts
T
Hartmut dfeb4d361e fix(tests): align 20 drifted tests with current source behavior
Tests fell behind source changes: lastTotpAt replay-attack prevention,
activeSession invalidation on password reset, select clauses in
permission updates, UNAUTHORIZED (anti-enumeration) for disabled TOTP,
and password minimum raised from 8 to 12 characters.

Also fix root eslint.config.mjs to ignore packages/ (linted via turbo)
and add --no-warn-ignored to lint-staged to suppress warnings for
ignored files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 15:41:42 +02:00

314 lines
12 KiB
TypeScript

/**
* 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, rate limiting
* - getCurrentMfaStatus: status read
*
* otpauth is mocked so tests are deterministic and do not depend on
* time-based code generation.
* totpRateLimiter is mocked to allow/block independently of real Redis.
*/
import { TRPCError } from "@trpc/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
// ─── otpauth mock ────────────────────────────────────────────────────────────
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 };
});
// ─── rate-limit mock ──────────────────────────────────────────────────────────
// Default: rate limit allows all requests. Override in specific tests.
const totpRateLimiterMock = vi.hoisted(() =>
vi.fn(async (_key: string) => ({
allowed: true,
remaining: 9,
resetAt: new Date(Date.now() + 30_000),
})),
);
vi.mock("../middleware/rate-limit.js", () => ({
totpRateLimiter: totpRateLimiterMock,
apiRateLimiter: vi.fn(async () => ({ allowed: true, remaining: 99, resetAt: new Date() })),
authRateLimiter: vi.fn(async () => ({ allowed: true, remaining: 4, resetAt: new Date() })),
}));
// ─── 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(),
update: vi.fn().mockResolvedValue({}),
...((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, lastTotpAt: expect.any(Date) },
});
});
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();
totpRateLimiterMock.mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: new Date(Date.now() + 30_000),
});
});
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 UNAUTHORIZED when user does not have MFA enabled (prevents user enumeration)", 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: "UNAUTHORIZED", message: "Invalid TOTP token." }));
});
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);
});
it("throws TOO_MANY_REQUESTS when rate limit is exceeded", async () => {
totpRateLimiterMock.mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: new Date(Date.now() + 30_000),
});
const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } });
await expect(
verifyTotp(ctx as Parameters<typeof verifyTotp>[0], { userId: "user_1", token: "123456" }),
).rejects.toThrow(
new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many TOTP attempts. Please wait before trying again.",
}),
);
});
it("does not check the token when rate limit is exceeded", async () => {
totpRateLimiterMock.mockResolvedValue({ allowed: false, remaining: 0, resetAt: new Date() });
const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } });
await expect(
verifyTotp(ctx as Parameters<typeof verifyTotp>[0], { userId: "user_1", token: "123456" }),
).rejects.toThrow(TRPCError);
// DB user lookup must not happen when rate-limited
expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
});
it("calls the rate limiter with the userId as key", async () => {
totpValidateMock.mockReturnValue(0);
const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } });
await verifyTotp(ctx as Parameters<typeof verifyTotp>[0], {
userId: "user_1",
token: "123456",
});
expect(totpRateLimiterMock).toHaveBeenCalledWith("user_1");
});
});
// ─── 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 });
});
});