dfeb4d361e
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>
314 lines
12 KiB
TypeScript
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 });
|
|
});
|
|
});
|