/** * 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(); }); });