test(mfa): end-to-end coverage, OpenAPI and documentation

This commit is contained in:
2026-09-16 14:18:39 +00:00
parent 14e9db606a
commit 142b99e408
8 changed files with 1762 additions and 42 deletions
+4
View File
@@ -13,6 +13,10 @@ installer/nssm.zip
installer/checksums-msi.txt
.next
*.tsbuildinfo
web/test-results
web/playwright-report
web/blob-report
web/playwright/.cache
graphify-out
docker-compose.live.yml
.claude
+99 -1
View File
@@ -757,6 +757,93 @@ reference that lies. Scalar is vendored (`scalar.standalone.js`, served from
reference page has to work on an air-gapped install with no outbound access at
all - the same requirement licence verification already meets.
### Multi-factor authentication
Local and `hq`-sourced members can enrol TOTP and WebAuthn passkeys; OIDC users
are exempt (`auth_source == "oidc"`), since their IdP owns authentication.
**A password that checks out mints a pending-login ticket, not a session with
an `mfa_pending` flag.** The ticket is a Redis key (`km:mfa:<id>`, 5 min TTL)
referenced by a separate `km_mfa_pending` cookie; only the `/auth/mfa/*` and
`/auth/mfa/enrol/*` endpoints accept it, and it exchanges for a `km_session`
only on a completed second factor or completed enrolment. A flag on `Session`
would fail open - any route mounted under `auth.Middleware`, today's or a
future one, could serve a half-authenticated user by forgetting to check it.
A ticket fails closed: nothing under `auth.Middleware` recognises it at all,
because it is never a `*Session`. `Session` itself gained `AMR []string`
(`pwd`, `otp`, `webauthn`, `recovery`, `oidc`) and `StepUpAt *time.Time`; sign-in
counts as a step-up, so `StepUpAt` is set at session creation.
**The pending ticket's attempt counter is an atomic Redis counter
(`km:mfa:<id>:attempts`), not a field rewritten on the ticket document.** Two
requests racing to fail a guess would otherwise both read the same `attempts`
and both write it back incremented once, undercounting. `INCR` has no such
race; the fifth failure deletes the ticket.
**The TOTP replay guard is keyed on the time step, not the code**:
`km:totp:<user_id>:<step>`, `SET NX` with a 90s TTL. Keying on the code itself
would let the same 6 digits be replayed across two different steps that
happen to compute it (a 1-in-a-million collision, but a free one to close);
keying on the step means a given 30-second window can be spent exactly once,
which is what "single-use" actually means for a TOTP code.
**`user_mfa.totp_pending_enc` holds an unconfirmed TOTP secret and is
deliberately not in `vantage-shared`'s `backup.ciphertextFields["user_mfa"]`**,
which lists only `totp_secret_enc`. The confirmed secret is the one that
authenticates anyone; an abandoned setup attempt (scanned once, never
confirmed, replaced by the next `POST /me/mfa/totp/setup` call) is not worth
widening the backup contract's surface for. `vantagectl verify`'s live probe
therefore never touches it - this is intentional, not the same silent gap the
ciphertext-field mirror otherwise guards against.
**Two new collections**, both in `ScopedCollections` so an instance purge
removes them, both with a fatal index builder like `EnsureAuthIndexes`:
- `user_mfa` - one document per user who has started enrolment. Unique index
`{instance_id, user_id}`. `totp_confirmed_at: nil` means setup started but
TOTP is not active; "has MFA" means that field is set or the user owns a
passkey.
- `webauthn_credentials` - one document per passkey. Unique index
`{instance_id, credential_id}`, plus `{instance_id, user_id}`. `sign_count`
backs clone detection: a non-increasing non-zero count fails the assertion.
`require_mfa` (`models.Settings.RequireMFA bool`, `bson:"require_mfa"`)
shipped in `vantage-shared` v0.7.0 - a plain bool because absent must mean off.
Switching it on does not revoke existing sessions; they end at their normal
24h TTL, and the next sign-in enforces enrolment for anyone with no factor yet.
**WebAuthn's RP ID is the request host with any port stripped**, resolved
per-request rather than configured, the same way the org/host guard resolves
an instance from `<slug>.vantage.<tld>`. A passkey is bound to the host it was
registered on: moving a self-hosted instance to a new domain, or renaming a
cloud instance (see "A rename moves the host" above), invalidates every
passkey on it. TOTP and recovery codes are unaffected, since they carry no
host binding. The docs say so; there is no migration path for a passkey
across a host change.
**Step-up** (`auth.RequireStepUp()`) gates three existing sensitive routes -
`POST /api/secrets/:group/reveal`, `GET /api/keys/:id/private-key`,
`POST /api/console/connect` - plus the MFA-management endpoints that create or
remove a factor. It passes when `StepUpAt` is within the last **ten minutes**,
when the session's `AMR` contains `oidc` (the IdP's own session policy
governs), or **when the request authenticated with an API token**
(`TokenID != ""`). That last exemption is a known, accepted gap, not an
oversight: a token has no human present to prompt for a second factor, so a
token holding `secrets:read` or `keys:read` can reveal a secret or download a
private key with no re-authentication at all. The mitigation is scoped,
short-lived tokens, tracked separately in the gap review, not a code change
here - a later reviewer should not "fix" this silently. `POST /api/me/step-up`
takes `{totp}`, `{recovery}` or `{password}` (password only for a user with no
MFA); `POST /api/me/step-up/webauthn/begin` and `/finish` do the same with a
passkey. All three, like every unauthenticated MFA endpoint, sit behind
`RateLimitAuth()` - a fixed Redis window, 20 requests/minute per
`c.ClientIP()`, answering 429 with `Retry-After` - on the `RateLimitTokens`
pattern but for sign-in and re-authentication rather than API tokens.
Library versions: `github.com/pquerna/otp` for TOTP, `github.com/go-webauthn/webauthn`
**v0.18.1** for WebAuthn ceremonies, `qrcode` (npm) to draw the enrolment QR
client-side so an air-gapped install needs nothing external.
### The public host
**vantage.hostxtra.co.uk is not served by this repository.** The marketing site
@@ -967,6 +1054,11 @@ POST /auth/bootstrap /auth/login /auth/logout
GET /auth/me
GET /auth/providers # {local_enabled, providers:[{id,name,preset}]} - no issuer, client ID or secret
GET /api/secrets/:group/values # bearer token (ESO)
POST /auth/mfa/totp /auth/mfa/recovery # second factor against a pending-login ticket
POST /auth/mfa/webauthn/begin /finish
POST /auth/passkey/begin /auth/passkey/finish # passwordless sign-in
POST /auth/mfa/enrol/totp/setup /confirm # ticket-scoped forced enrolment
POST /auth/mfa/enrol/passkey/begin /finish
```
Session-authed under `/api`:
@@ -1012,6 +1104,12 @@ agent GET /agent/latest-version
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
licence GET /license · POST /license (POST: self-hosted only)
org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users/:id
DELETE /org/users/:id/mfa (owner|admin, step-up)
mfa GET /me/mfa · POST /me/mfa/totp/setup (step-up)
POST /me/mfa/totp/confirm · DELETE /me/mfa/totp (step-up)
POST /me/mfa/recovery/regenerate (step-up)
POST,PATCH,DELETE /me/passkeys[/begin,/finish,/:id] (step-up, except rename)
POST /me/step-up · POST /me/step-up/webauthn/begin /finish
providers GET,POST /auth/providers · PUT,DELETE /auth/providers/:id
POST /auth/providers/:id/{test,ack-notice} · GET /auth/presets (owner|admin)
tokens GET /tokens · GET /tokens/scopes · POST /tokens · DELETE /tokens/:id
@@ -1067,7 +1165,7 @@ plane, each of which this codebase enforces:
## MongoDB Collections
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `status_pages` · `status_incidents` · `maintenance_windows` · `patch_policies` · `patch_runs` · `patch_run_outputs` · `migrations`
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `status_pages` · `status_incidents` · `maintenance_windows` · `patch_policies` · `patch_runs` · `patch_run_outputs` · `user_mfa` · `webauthn_credentials` · `migrations`
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth - see `server/internal/models/`.
File diff suppressed because it is too large Load Diff
+237
View File
@@ -0,0 +1,237 @@
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<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;
}
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);
});
});
+124
View File
@@ -17,6 +17,7 @@
"tailwind-merge": "^2.4.0"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"@types/node": "^20.14.11",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.3",
@@ -24,6 +25,7 @@
"autoprefixer": "^10.4.19",
"eslint": "^9.0.0",
"eslint-config-next": "16.2.9",
"otplib": "^12.0.1",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3"
@@ -1231,6 +1233,78 @@
"node": ">=12.4.0"
}
},
"node_modules/@otplib/core": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@otplib/plugin-crypto": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
"dev": true,
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1"
}
},
"node_modules/@otplib/plugin-thirty-two": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
"dev": true,
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"thirty-two": "^1.0.2"
}
},
"node_modules/@otplib/preset-default": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
"dev": true,
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@otplib/preset-v11": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -5133,6 +5207,18 @@
"node": ">= 0.8.0"
}
},
"node_modules/otplib": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/preset-default": "^12.0.1",
"@otplib/preset-v11": "^12.0.1"
}
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -5270,6 +5356,35 @@
"node": ">= 6"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
@@ -6420,6 +6535,15 @@
"node": ">=0.8"
}
},
"node_modules/thirty-two": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
"dev": true,
"engines": {
"node": ">=0.2.6"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+4 -1
View File
@@ -6,7 +6,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"test:e2e": "playwright test"
},
"dependencies": {
"@tanstack/react-query": "^5.51.1",
@@ -18,6 +19,8 @@
"tailwind-merge": "^2.4.0"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"otplib": "^12.0.1",
"@types/node": "^20.14.11",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.3",
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Minimal Playwright setup for the MFA end-to-end coverage (Task 15).
*
* There was no Playwright config in this repository before this task, so
* this is the smallest one that works: one project (Chromium, the only
* browser that implements the WebAuthn virtual authenticator CDP domain the
* passkey tests need), pointed at an already-running stack.
*
* Prerequisites (not started by this config - the stack needs MongoDB and
* Redis, which `webServer` cannot provision):
* docker compose -f ../deploy/docker/docker-compose.yml up -d
* npm run dev # or the built `server`/`web`, whichever is already running
*
* Run with: npx playwright test e2e/mfa.spec.ts
*/
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1,
reporter: "list",
use: {
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
trace: "retain-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+3 -1
View File
@@ -36,6 +36,8 @@
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
"node_modules",
"e2e",
"playwright.config.ts"
]
}