204 lines
9.6 KiB
TypeScript
204 lines
9.6 KiB
TypeScript
import { test, expect, type APIRequestContext, type BrowserContext, type Page } from "@playwright/test";
|
|
import { authenticator } from "otplib";
|
|
import { BASE_URL, ensureOwner, signIn } from "./helpers";
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
|
|
/** 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<string> {
|
|
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;
|
|
}
|
|
|
|
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 signIn(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 signIn(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 signIn(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 expect(page.getByText(/Added .* · Last used/)).toBeVisible();
|
|
|
|
await page.request.post(`${BASE_URL}/auth/logout`);
|
|
await signIn(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 signIn(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 signIn(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("fresh sign-in reveals a secret without a step-up prompt", async ({ page, request }) => {
|
|
const { email, password } = await createMember(request);
|
|
|
|
// Enrol TOTP so the account has a factor - not exercised in this test,
|
|
// since sign-in itself sets StepUpAt and the window has not gone stale.
|
|
await signIn(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();
|
|
|
|
// Sign-in counts as a step-up (mintSession sets StepUpAt), and that is
|
|
// valid for ten minutes - a browser test cannot wait that long or age a
|
|
// session, so this only asserts the fresh-session path: no prompt on the
|
|
// first reveal, and none on a second reveal right after. The stale path
|
|
// (StepUpAt older than ten minutes) is covered by TestStepUpFresh in
|
|
// server/internal/auth/stepup_test.go.
|
|
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")).toHaveCount(0);
|
|
await expect(page.locator("span.font-mono.text-xs.break-all")).toBeVisible();
|
|
|
|
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);
|
|
await expect(page.locator("span.font-mono.text-xs.break-all")).toBeVisible();
|
|
});
|
|
});
|