From d3bfa8ca98afda026c0d428795f66896298701a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 22:30:36 +0200 Subject: [PATCH] =?UTF-8?q?test(mfa):=20full=20MFA=20test=20coverage=20?= =?UTF-8?q?=E2=80=94=20unit=20+=20E2E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/e2e/dev-system/mfa.spec.ts | 261 ++++++++++++++++++ .../__tests__/user-self-service-mfa.test.ts | 241 ++++++++++++++++ scripts/start.sh | 11 +- 3 files changed, 510 insertions(+), 3 deletions(-) create mode 100644 apps/web/e2e/dev-system/mfa.spec.ts create mode 100644 packages/api/src/__tests__/user-self-service-mfa.test.ts diff --git a/apps/web/e2e/dev-system/mfa.spec.ts b/apps/web/e2e/dev-system/mfa.spec.ts new file mode 100644 index 0000000..c5754ee --- /dev/null +++ b/apps/web/e2e/dev-system/mfa.spec.ts @@ -0,0 +1,261 @@ +/** + * E2E tests for MFA (TOTP) flows. + * + * Coverage: + * 1. MFA setup: generate secret, enter valid TOTP, confirm enabled + * 2. MFA login: sign in with password → TOTP prompt → enter code → dashboard + * 3. MFA login: wrong TOTP code → error message, stays on prompt + * 4. Login without MFA: no TOTP prompt for users without MFA enabled + * + * Design notes: + * - Uses the admin user from STORAGE_STATE to set up/tear down MFA via tRPC. + * - TOTP codes are generated in Node context using the `otpauth` package. + * - Each test that enables MFA cleans up via disableMfa() in afterEach so + * other test suites are not affected. + * - Tests run against the live dev server (playwright.dev.config.ts). + */ + +import { expect, test, type Page } from "@playwright/test"; +import { TOTP, Secret } from "otpauth"; +import { STORAGE_STATE } from "../../playwright.dev.config.js"; + +// ─── tRPC helpers ───────────────────────────────────────────────────────────── + +type TrpcResult = { result?: { data?: unknown }; error?: { data?: { code?: string }; message?: string } }; + +async function trpcMutation(page: Page, procedure: string, input: unknown = null): Promise { + return page.evaluate( + async ({ procedure, input }) => { + const res = await fetch(`/api/trpc/${procedure}?batch=1`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ "0": { json: input } }), + }); + const body = (await res.json()) as TrpcResult[]; + return body[0] ?? {}; + }, + { procedure, input }, + ); +} + +async function trpcQuery(page: Page, procedure: string, input: unknown = null): Promise { + return page.evaluate( + async ({ procedure, input }) => { + const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } })); + const res = await fetch(`/api/trpc/${procedure}?batch=1&input=${encodedInput}`, { + credentials: "include", + }); + const body = (await res.json()) as TrpcResult[]; + return body[0] ?? {}; + }, + { procedure, input }, + ); +} + +// Enable MFA for the session user and return the TOTP instance for code generation. +async function enableMfaForSession(page: Page): Promise { + const genRes = await trpcMutation(page, "user.generateTotpSecret"); + const data = (genRes.result?.data as { json?: { secret?: string; uri?: string } })?.json; + if (!data?.secret) throw new Error(`generateTotpSecret failed: ${JSON.stringify(genRes)}`); + + const totp = new TOTP({ + issuer: "CapaKraken", + algorithm: "SHA1", + digits: 6, + period: 30, + secret: Secret.fromBase32(data.secret), + }); + + const token = totp.generate(); + const enableRes = await trpcMutation(page, "user.verifyAndEnableTotp", { token }); + if (enableRes.error) throw new Error(`verifyAndEnableTotp failed: ${JSON.stringify(enableRes)}`); + + return totp; +} + +// Disable MFA for the session user. Fetches current user profile first to get the userId. +async function disableMfaForSession(page: Page): Promise { + const profileRes = await trpcQuery(page, "user.getCurrentUserProfile"); + const profile = (profileRes.result?.data as { json?: { id?: string } })?.json; + if (!profile?.id) throw new Error("Could not resolve current user id for MFA disable"); + await trpcMutation(page, "user.disableTotp", { userId: profile.id }); +} + +// ─── test suite ─────────────────────────────────────────────────────────────── + +test.describe("MFA — setup flow (account/security page)", () => { + test.use({ storageState: STORAGE_STATE.admin }); + + let totp: TOTP | null = null; + + test.afterEach(async ({ page }) => { + // Clean up: disable MFA if a test enabled it + if (totp) { + await disableMfaForSession(page).catch(() => {/* already disabled or admin override */}); + totp = null; + } + }); + + test("generates a TOTP secret and returns a valid otpauth URI", async ({ page }) => { + await page.goto("/account/security"); + await page.waitForLoadState("networkidle"); + + const genRes = await trpcMutation(page, "user.generateTotpSecret"); + const data = (genRes.result?.data as { json?: { secret?: string; uri?: string } })?.json; + + expect(data?.secret).toBeTruthy(); + expect(data?.uri).toMatch(/^otpauth:\/\/totp\//); + expect(data?.uri).toContain("CapaKraken"); + }); + + test("verifyAndEnableTotp accepts a valid code and enables MFA", async ({ page }) => { + await page.goto("/account/security"); + + totp = await enableMfaForSession(page); + + // Verify status via tRPC + const statusRes = await trpcQuery(page, "user.getMfaStatus"); + const status = (statusRes.result?.data as { json?: { totpEnabled?: boolean } })?.json; + expect(status?.totpEnabled).toBe(true); + }); + + test("verifyAndEnableTotp rejects an invalid 6-digit code", async ({ page }) => { + await page.goto("/account/security"); + + // First generate a secret + await trpcMutation(page, "user.generateTotpSecret"); + + // Submit obviously wrong code + const res = await trpcMutation(page, "user.verifyAndEnableTotp", { token: "000000" }); + expect(res.error?.data?.code).toBe("BAD_REQUEST"); + expect(res.error?.message).toMatch(/Invalid TOTP token/i); + }); + + test("setup UI shows QR code and secret fields after generating", async ({ page }) => { + await page.goto("/account/security"); + await page.waitForLoadState("networkidle"); + + // Click the enable/setup button if MFA is not yet enabled + const setupBtn = page.getByRole("button", { name: /set up/i }).or( + page.getByRole("button", { name: /enable.*mfa/i }), + ); + + if (await setupBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await setupBtn.click(); + // QR code image or canvas should appear + await expect( + page.locator('img[alt*="QR"], canvas, [data-testid="qr-code"]').first(), + ).toBeVisible({ timeout: 5000 }); + } + }); +}); + +// ─── MFA login flow ─────────────────────────────────────────────────────────── + +test.describe("MFA — login flow", () => { + // These tests need a fresh unauthenticated browser context + test.use({ storageState: { cookies: [], origins: [] } }); + + let totp: TOTP | null = null; + + // Enable MFA on admin before each login test using a separate authenticated context + test.beforeEach(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: STORAGE_STATE.admin }); + const page = await ctx.newPage(); + await page.goto("/dashboard"); + totp = await enableMfaForSession(page); + await ctx.close(); + }); + + test.afterEach(async ({ browser }) => { + if (!totp) return; + // Re-authenticate as admin (without MFA — use admin bypass via fresh session) + // Since we just enabled MFA on the admin account, we need to sign in with TOTP to disable + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/auth\/signin/, { timeout: 5000 }).catch(() => {}); + + // Enter TOTP if prompted + const totpInput = page.locator("#totp"); + if (await totpInput.isVisible({ timeout: 3000 }).catch(() => false)) { + await totpInput.fill(totp!.generate()); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/(dashboard|resources)/, { timeout: 10000 }).catch(() => {}); + } + + // Disable MFA + await disableMfaForSession(page).catch(() => {}); + await ctx.close(); + totp = null; + }); + + test("login with MFA-enabled account: password step shows TOTP prompt", async ({ page }) => { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + + // Should NOT redirect to dashboard — should show TOTP input + await expect(page.locator("#totp")).toBeVisible({ timeout: 8000 }); + await expect(page.getByText(/two-factor/i)).toBeVisible(); + }); + + test("login with MFA: valid TOTP code completes sign-in", async ({ page }) => { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + + await expect(page.locator("#totp")).toBeVisible({ timeout: 8000 }); + + const code = totp!.generate(); + await page.fill("#totp", code); + await page.click('button[type="submit"]'); + + await expect(page).toHaveURL(/\/(dashboard|resources)/, { timeout: 12000 }); + }); + + test("login with MFA: wrong TOTP code shows error and stays on MFA prompt", async ({ page }) => { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + + await expect(page.locator("#totp")).toBeVisible({ timeout: 8000 }); + + await page.fill("#totp", "000000"); + await page.click('button[type="submit"]'); + + // Should show error and remain on TOTP step + await expect( + page.getByText(/invalid.*code|incorrect.*token|try again/i).or( + page.locator("[data-error]"), + ).first(), + ).toBeVisible({ timeout: 5000 }); + + // Should NOT have navigated away + expect(page.url()).not.toMatch(/\/(dashboard|resources)/); + }); +}); + +// ─── Login without MFA ──────────────────────────────────────────────────────── + +test.describe("MFA — users without MFA enabled", () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test("login for MFA-less user goes straight to dashboard without TOTP prompt", async ({ page }) => { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "manager@planarchy.dev"); + await page.fill('input[type="password"]', "manager123"); + await page.click('button[type="submit"]'); + + // No TOTP step — straight redirect + await expect(page).toHaveURL(/\/(dashboard|resources)/, { timeout: 12000 }); + await expect(page.locator("#totp")).not.toBeVisible(); + }); +}); diff --git a/packages/api/src/__tests__/user-self-service-mfa.test.ts b/packages/api/src/__tests__/user-self-service-mfa.test.ts new file mode 100644 index 0000000..fdc607f --- /dev/null +++ b/packages/api/src/__tests__/user-self-service-mfa.test.ts @@ -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 = {}) { + 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(), + roleDefaults: null, + }; +} + +function makePublicCtx(dbOverrides: Record = {}) { + 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[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[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[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[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[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[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[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[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[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[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[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[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[0]); + expect(result).toEqual({ totpEnabled: false }); + }); +}); diff --git a/scripts/start.sh b/scripts/start.sh index 1fa9e96..d93d2e1 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -23,8 +23,9 @@ echo " Starting app container on port 3100..." docker compose --profile full up -d app # 4. Wait for server to be ready -echo " Waiting for server..." -for i in {1..30}; do +# Allow up to 90s: prisma generate + migrate deploy + next dev compilation +echo " Waiting for server (up to 90s)..." +for i in {1..90}; do if curl -sf http://localhost:3100/api/health > /dev/null 2>&1; then echo "" echo "CapaKraken is running!" @@ -34,9 +35,13 @@ for i in {1..30}; do echo " Logs: docker logs -f capakraken-app-1" exit 0 fi + # Print progress every 10s + if (( i % 10 == 0 )); then + echo " Still waiting... (${i}s)" + fi sleep 1 done -echo "ERROR: Server failed to start within 30 seconds" +echo "ERROR: Server failed to start within 90 seconds" echo "Check logs: docker logs --tail 100 capakraken-app-1" exit 1