import { test, expect, type APIRequestContext, type BrowserContext, type Page } from "@playwright/test"; import { authenticator } from "otplib"; /** * End-to-end coverage for Task 15 of the MFA design * (docs/superpowers/specs/2026-09-15-mfa-local-signin-design.md). * * Prerequisites: a running control plane (server + web) backed by MongoDB * and Redis, reachable at `E2E_BASE_URL` (default http://localhost:3000) * with the API at the same origin (the nginx fragment in * deploy/docker/docker-compose.yml, or an equivalent dev proxy). * * The suite needs an existing owner session to create fresh member accounts * per test, so state never leaks between tests. If the instance has never * been bootstrapped, the first test bootstraps it and every other test reuses * those owner credentials; set E2E_OWNER_EMAIL / E2E_OWNER_PASSWORD to point * at an already-bootstrapped instance's owner instead. */ const BASE_URL = process.env.E2E_BASE_URL ?? "http://localhost:3000"; let ownerEmail = process.env.E2E_OWNER_EMAIL ?? ""; let ownerPassword = process.env.E2E_OWNER_PASSWORD ?? ""; /** Ensures an owner account exists and returns its credentials, bootstrapping the instance if needed. */ async function ensureOwner(request: APIRequestContext): Promise<{ email: string; password: string }> { if (ownerEmail && ownerPassword) return { email: ownerEmail, password: ownerPassword }; const status = await request.get(`${BASE_URL}/auth/bootstrap-status`); const body = await status.json(); if (!body.needs_setup) { throw new Error( "Instance is already bootstrapped and E2E_OWNER_EMAIL/E2E_OWNER_PASSWORD were not set - " + "cannot create the owner account this suite needs to provision fresh members per test.", ); } ownerEmail = `e2e-owner-${Date.now()}@vantage.test`; ownerPassword = "correct horse battery staple 1"; const res = await request.post(`${BASE_URL}/auth/bootstrap`, { data: { instance_name: "MFA E2E", email: ownerEmail, password: ownerPassword }, }); if (!res.ok()) throw new Error(`bootstrap failed: ${res.status()} ${await res.text()}`); return { email: ownerEmail, password: ownerPassword }; } /** Creates a fresh, unique local member with no MFA enrolled, using an owner session. */ async function createMember(request: APIRequestContext): Promise<{ email: string; password: string }> { const owner = await ensureOwner(request); const login = await request.post(`${BASE_URL}/auth/login`, { data: { email: owner.email, password: owner.password }, }); if (!login.ok()) throw new Error(`owner login failed: ${login.status()} ${await login.text()}`); const email = `e2e-member-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@vantage.test`; const password = "correct horse battery staple 1"; const res = await request.post(`${BASE_URL}/api/org/users`, { data: { email, password, role: "member" }, }); if (!res.ok()) throw new Error(`create member failed: ${res.status()} ${await res.text()}`); await request.post(`${BASE_URL}/auth/logout`); return { email, password }; } /** Registers Chrome's virtual authenticator (CTAP2, resident keys, internal UV) on this context. */ async function addVirtualAuthenticator(context: BrowserContext, page: Page): Promise { const client = await context.newCDPSession(page); await client.send("WebAuthn.enable"); const { authenticatorId } = await client.send("WebAuthn.addVirtualAuthenticator", { options: { protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, }, }); return authenticatorId; } async function fillCredentials(page: Page, email: string, password: string) { await page.goto("/login"); await page.getByLabel("Email").fill(email); await page.getByLabel("Password").fill(password); await page.getByRole("button", { name: "Sign In" }).click(); } test.describe("MFA end-to-end", () => { test("TOTP sign-in", async ({ page, request }) => { const { email, password } = await createMember(request); // Sign in, land on the account security page, enrol TOTP. await fillCredentials(page, email, password); await expect(page).toHaveURL("/"); await page.goto("/account/security"); await page.getByRole("button", { name: "Set up" }).click(); await page.getByRole("button", { name: "Use an authenticator app" }).click(); const secret = await page.locator("p.font-mono").innerText(); const code = authenticator.generate(secret.trim()); await page.getByLabel("6-digit code").fill(code); await page.getByRole("button", { name: "Confirm" }).click(); // Recovery codes step - acknowledge and finish. await page.getByLabel("I have saved these codes").check(); await page.getByRole("button", { name: "Continue" }).click(); await expect(page.getByText("Enabled")).toBeVisible(); // Sign out and sign back in - TOTP is now required. await page.request.post(`${BASE_URL}/auth/logout`); await fillCredentials(page, email, password); await expect(page.getByText("Enter the 6-digit code")).toBeVisible(); const nextCode = authenticator.generate(secret.trim()); await page.getByLabel("Verification code").fill(nextCode); await page.getByRole("button", { name: "Verify" }).click(); await expect(page).toHaveURL("/"); }); test("passkey second factor", async ({ page, context, request }) => { const { email, password } = await createMember(request); await addVirtualAuthenticator(context, page); await fillCredentials(page, email, password); await expect(page).toHaveURL("/"); await page.goto("/account/security"); await page.getByRole("button", { name: "Set up" }).click(); await page.getByRole("button", { name: "Use a passkey" }).click(); await page.getByLabel("I have saved these codes").check(); await page.getByRole("button", { name: "Continue" }).click(); await expect(page.getByText("No passkeys registered.")).toHaveCount(0); await page.request.post(`${BASE_URL}/auth/logout`); await fillCredentials(page, email, password); await expect(page.getByText("Enter the 6-digit code")).toBeVisible(); await page.getByRole("button", { name: "Use passkey" }).click(); await expect(page).toHaveURL("/"); }); test("passwordless passkey sign-in", async ({ page, context, request }) => { const { email, password } = await createMember(request); await addVirtualAuthenticator(context, page); // Enrol a passkey first (typing the password once, during setup only). await fillCredentials(page, email, password); await expect(page).toHaveURL("/"); await page.goto("/account/security"); await page.getByRole("button", { name: "Add a passkey" }).click(); await page.request.post(`${BASE_URL}/auth/logout`); // Sign back in with no password typed at all. await page.goto("/login"); await page.getByRole("button", { name: "Sign in with passkey" }).click(); await expect(page).toHaveURL("/"); }); test("forced enrolment when require_mfa is on", async ({ page, request }) => { const owner = await ensureOwner(request); const { email, password } = await createMember(request); // Owner enables the policy. const ownerLogin = await request.post(`${BASE_URL}/auth/login`, { data: { email: owner.email, password: owner.password }, }); expect(ownerLogin.ok()).toBeTruthy(); const settingsRes = await request.put(`${BASE_URL}/api/settings`, { data: { require_mfa: true }, }); expect(settingsRes.ok()).toBeTruthy(); await request.post(`${BASE_URL}/auth/logout`); // The member, who has no MFA yet, must enrol before reaching the app. await fillCredentials(page, email, password); await expect(page.getByText("This instance requires a second sign-in factor. Set one up to continue.")).toBeVisible(); await page.getByRole("button", { name: "Use an authenticator app" }).click(); const secret = await page.locator("p.font-mono").innerText(); const code = authenticator.generate(secret.trim()); await page.getByLabel("6-digit code").fill(code); await page.getByRole("button", { name: "Confirm" }).click(); await page.getByLabel("I have saved these codes").check(); await page.getByRole("button", { name: "Continue" }).click(); await expect(page).toHaveURL("/"); // Clean up: turn the policy back off so it doesn't affect other tests. await request.post(`${BASE_URL}/auth/login`, { data: { email: owner.email, password: owner.password } }); await request.put(`${BASE_URL}/api/settings`, { data: { require_mfa: false } }); await request.post(`${BASE_URL}/auth/logout`); }); test("step-up on secret reveal", async ({ page, request }) => { const { email, password } = await createMember(request); // Enrol TOTP so step-up has a factor to challenge. await fillCredentials(page, email, password); await page.goto("/account/security"); await page.getByRole("button", { name: "Set up" }).click(); await page.getByRole("button", { name: "Use an authenticator app" }).click(); const secret = await page.locator("p.font-mono").innerText(); await page.getByLabel("6-digit code").fill(authenticator.generate(secret.trim())); await page.getByRole("button", { name: "Confirm" }).click(); await page.getByLabel("I have saved these codes").check(); await page.getByRole("button", { name: "Continue" }).click(); // A brand-new session's StepUpAt is fresh from sign-in, so create a secret, // then create a group via the UI and attempt a reveal - the modal should // still appear because sign-in only counts as step-up for ten minutes and // this test does not wait that long; it is testing the prompt fires the // first time an authenticated caller with no fresh step-up reveals one. await request.post(`${BASE_URL}/api/secrets`, { data: { group: "e2e-stepup", values: { KEY: "value" } } }); await page.goto("/secrets/e2e-stepup"); await page.getByRole("button", { name: "Reveal" }).click(); await expect(page.getByText("Confirm it's you")).toBeVisible(); await page.getByLabel("6-digit code").fill(authenticator.generate(secret.trim())); await page.getByRole("button", { name: "Confirm" }).click(); await expect(page.getByText("Confirm it's you")).toHaveCount(0); await expect(page.locator("span.font-mono.text-xs.break-all")).toBeVisible(); // Reveal again within the ten-minute step-up window: no prompt this time. await page.getByRole("button", { name: "Hide" }).click().catch(() => {}); await page.getByRole("button", { name: "Reveal" }).click(); await expect(page.getByText("Confirm it's you")).toHaveCount(0); }); });