import { expect, test, type Locator, type Page } from "@playwright/test"; import { createRequire } from "node:module"; const axePath = createRequire(import.meta.url).resolve("axe-core/axe.min.js"); const currentPassword = "current-password-fixture"; const nextPassword = "new-password-fixture"; const recoveryCode = "pr_fixture-code-never-a-real-secret"; const policy = { recovery_enabled: true, min_length: 10, max_length: 1024, recovery_minutes: 15 }; async function mockPasswordApi(page: Page, options: { enabled?: boolean; failChange?: boolean; failRecovery?: boolean } = {}) { const posts: Array<{ path: string; body: Record }> = []; await page.route("**/api/v1/**", async (route) => { const request = route.request(); const path = new URL(request.url()).pathname; if (request.method() === "POST") posts.push({ path, body: request.postDataJSON() }); const json = (data: unknown, status = 200) => route.fulfill({ status, contentType: "application/json", body: JSON.stringify(data) }); if (path === "/api/v1/auth/password/policy") return json({ ...policy, recovery_enabled: options.enabled ?? true }); if (path === "/api/v1/auth/password/change") { if (options.failChange) return json({ detail: { code: "current_password_invalid", input: currentPassword } }, 403); return json({ user: { required_auth_action: null, local_password: true }, principal: { auth_method: "session", session_id: "rotated-session" } }); } if (path.startsWith("/api/v1/auth/password/recovery/")) return json({ recovery_code: recoveryCode, expires_at: "2026-10-01T10:15:00Z" }); if (path === "/api/v1/auth/password/recover") return options.failRecovery ? json({ detail: { code: "recovery_invalid", input: recoveryCode } }, 400) : json({ ok: true }); if (path === "/api/v1/admin/system/accounts/delta") return json({ accounts: [ { account_id: "target-local", email: "local@example.test", local_password: true, is_active: true, roles: [], memberships: [] }, { account_id: "target-external", email: "external@example.test", local_password: false, is_active: true, roles: [], memberships: [] } ], roles: [], deleted: [], watermark: "fixture", full: true, has_more: false }); if (path.endsWith("/tenants")) return json({ tenants: [] }); // All browser verification uses synthetic responses; nothing reaches a live provider. return json({ detail: "Unmocked fixture request" }, 404); }); return posts; } async function expectNoSecretsInStorage(page: Page) { const values = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } })); for (const secret of [currentPassword, nextPassword, recoveryCode]) { expect(values).not.toContain(secret); expect(page.url()).not.toContain(secret); } } async function expectHelpContext(control: Locator, context: string) { await expect(control).toBeVisible(); expect(await control.evaluate((element) => { const scoped = element.closest("[data-help-context-id]"); return { context: scoped?.dataset.helpContextId, module: scoped?.dataset.helpModuleId }; })).toEqual({ context, module: "access" }); } test("required-action F1 resolves public static help without privileged API calls or secret queries", async ({ page }) => { await mockPasswordApi(page); const apiRequests: string[] = []; page.on("request", (request) => { if (new URL(request.url()).pathname.startsWith("/api/v1/")) apiRequests.push(new URL(request.url()).pathname); }); await page.goto("/?password-lifecycle&mode=required&language=en"); const current = page.getByLabel("Current password", { exact: true }); await current.fill(currentPassword); await expectHelpContext(current, "access.password.change"); await expectHelpContext(page.getByLabel("New password", { exact: true }), "access.password.change"); await expectHelpContext(page.getByLabel("Confirm new password", { exact: true }), "access.password.change"); await expectHelpContext(page.getByRole("button", { name: "Change password", exact: true }), "access.password.change"); await current.press("F1"); const dialog = page.getByRole("dialog"); await expect(dialog.locator('[data-help-context="access.password.change"]')).toBeVisible(); await expect(dialog).toContainText("access.help.password-change"); await expect(dialog).not.toContainText(currentPassword); await page.evaluate(() => { window.open = (url) => { document.body.dataset.openedHelpUrl = String(url); return null; }; }); await dialog.getByRole("button", { name: "Open user documentation", exact: true }).click(); const opened = new URL(await page.locator("body").getAttribute("data-opened-help-url") ?? ""); expect(opened.origin).toBe("https://govoplan.add-ideas.de"); expect(opened.searchParams.get("topic")).toBe("access.help.password-change"); expect(opened.searchParams.get("module")).toBe("access"); expect(opened.href).not.toContain(currentPassword); expect(apiRequests.every((path) => path === "/api/v1/auth/password/policy")).toBe(true); await expect(page.getByText("Workspace available")).toHaveCount(0); await expectNoSecretsInStorage(page); }); test("recovery credentials, verification, one-time display and navigation have exact owning help", async ({ page }) => { await mockPasswordApi(page); await page.goto("/?password-lifecycle&mode=recover&language=en"); for (const label of ["Email", "Recovery code", "New password", "Confirm new password"]) { await expectHelpContext(page.getByLabel(label, { exact: true }), "access.password.recover"); } await expectHelpContext(page.getByRole("button", { name: "Recover local password", exact: true }), "access.password.recover"); await expectHelpContext(page.getByRole("link", { name: "Return to sign in", exact: true }), "access.password.recover"); await page.goto("/?password-lifecycle&mode=login&language=en"); await expectHelpContext(page.getByRole("link", { name: "Forgot your password?", exact: true }), "access.password.recover"); await page.goto("/?password-lifecycle&mode=admin&language=en"); await expectHelpContext(page.getByRole("button", { name: "Issue recovery code", exact: true }), "access.password.issue-recovery"); await page.goto("/?password-lifecycle&mode=issue&language=en"); const current = page.getByLabel("Current password", { exact: true }); await expectHelpContext(current, "access.password.issue-recovery"); const verified = page.getByRole("checkbox"); await expectHelpContext(verified, "access.password.issue-recovery"); const issue = page.getByRole("button", { name: "Issue recovery code", exact: true }); await expectHelpContext(issue, "access.password.issue-recovery"); await current.fill(currentPassword); await verified.check(); await issue.click(); await expectHelpContext(page.getByLabel("Recovery code", { exact: true }), "access.password.issue-recovery"); await expectHelpContext(page.getByRole("dialog").getByRole("button", { name: "Close", exact: true }).last(), "access.password.issue-recovery"); }); test("required password change gates workspace and accepts the rotated cookie session", async ({ page }) => { const posts = await mockPasswordApi(page); await page.goto("/?password-lifecycle&mode=required&language=en"); await expect(page.getByRole("heading", { name: "Change your initial password" })).toBeVisible(); await expect(page.getByText("Workspace available")).toHaveCount(0); await page.getByLabel("Current password", { exact: true }).fill(currentPassword); await page.getByLabel("New password", { exact: true }).fill(nextPassword); await page.getByLabel("Confirm new password", { exact: true }).fill(nextPassword); await page.getByRole("button", { name: "Change password", exact: true }).click(); await expect(page.getByRole("heading", { name: "Workspace available" })).toBeVisible(); expect(posts).toEqual([{ path: "/api/v1/auth/password/change", body: { current_password: currentPassword, new_password: nextPassword } }]); await expect(page.getByTestId("auth-update")).toHaveText(JSON.stringify({ action: null, token: "", session: "rotated-session" })); await expectNoSecretsInStorage(page); }); test("missing optional auth UI keeps the required account out of the workspace", async ({ page }) => { await mockPasswordApi(page); await page.goto("/?password-lifecycle&mode=missing&language=en"); await expect(page.getByText(/A required account action must be completed/)).toBeVisible(); await expect(page.getByText("Workspace available")).toHaveCount(0); }); test("password limits count Unicode code points, including astral characters", async ({ page }) => { const posts = await mockPasswordApi(page); const unicodeCurrent = "🔑".repeat(600); const unicodeNext = "🔐".repeat(1024); await page.goto("/?password-lifecycle&mode=settings&language=en"); await page.getByLabel("Current password", { exact: true }).fill(unicodeCurrent); const password = page.getByLabel("New password", { exact: true }); const confirmation = page.getByLabel("Confirm new password", { exact: true }); await password.fill("a".repeat(1025)); await confirmation.fill("a".repeat(1025)); await expect(page.getByRole("button", { name: "Change password", exact: true })).toBeDisabled(); await password.fill(unicodeNext); await confirmation.fill(unicodeNext); await expect(password).toHaveValue(unicodeNext); await page.getByRole("button", { name: "Change password", exact: true }).click(); await expect(page.getByTestId("auth-update")).toContainText("rotated-session"); expect(posts[0].body).toEqual({ current_password: unicodeCurrent, new_password: unicodeNext }); }); test("self-service is available with recovery disabled and clears rejected credentials", async ({ page }) => { const posts = await mockPasswordApi(page, { enabled: false, failChange: true }); await page.goto("/?password-lifecycle&mode=settings&language=en"); await page.getByLabel("Current password", { exact: true }).fill(currentPassword); await page.getByLabel("New password", { exact: true }).fill(nextPassword); await page.getByLabel("Confirm new password", { exact: true }).fill(nextPassword); await page.getByRole("button", { name: "Change password", exact: true }).click(); await expect(page.getByText(/Your current password was not accepted/)).toBeVisible(); expect(posts).toHaveLength(1); for (const label of ["Current password", "New password", "Confirm new password"]) await expect(page.getByLabel(label, { exact: true })).toHaveValue(""); await expect(page.locator("body")).not.toContainText(currentPassword); await expectNoSecretsInStorage(page); }); test("external accounts and API-key sessions cannot use the password change form", async ({ page }) => { const posts = await mockPasswordApi(page); for (const query of ["external=true", "api-key=true"]) { await page.goto(`/?password-lifecycle&mode=settings&language=en&${query}`); await expect(page.getByText(/Password changes require an interactive session/)).toBeVisible(); await expect(page.getByLabel("Current password", { exact: true })).toHaveCount(0); } expect(posts).toHaveLength(0); }); test("recovery replaces the password without signing in and clears all secrets", async ({ page }) => { const posts = await mockPasswordApi(page); await page.goto("/?password-lifecycle&mode=recover&language=en"); await page.getByLabel("Email", { exact: true }).fill("person@example.test"); await page.getByLabel("Recovery code", { exact: true }).fill(recoveryCode); await page.getByLabel("New password", { exact: true }).fill(nextPassword); await page.getByLabel("Confirm new password", { exact: true }).fill(nextPassword); await page.getByRole("button", { name: "Recover local password", exact: true }).click(); await expect(page.getByText(/Your password was replaced and existing sessions/)).toBeVisible(); expect(posts).toEqual([{ path: "/api/v1/auth/password/recover", body: { email: "person@example.test", recovery_code: recoveryCode, new_password: nextPassword } }]); await expect(page.getByTestId("auth-update")).toHaveText(""); await expect(page.getByRole("link", { name: "Return to sign in" })).toHaveAttribute("href", "/"); await expect(page.getByLabel("Recovery code", { exact: true })).toHaveCount(0); await expectNoSecretsInStorage(page); }); test("expired recovery codes show translated errors without reflecting response secrets", async ({ page }) => { await mockPasswordApi(page, { failRecovery: true }); await page.goto("/?password-lifecycle&mode=recover&language=de"); await page.getByLabel("E-Mail", { exact: true }).fill("person@example.test"); await page.getByLabel("Wiederherstellungscode", { exact: true }).fill(recoveryCode); await page.getByLabel("Neues Passwort", { exact: true }).fill(nextPassword); await page.getByLabel("Neues Passwort bestätigen", { exact: true }).fill(nextPassword); await page.getByRole("button", { name: "Lokales Passwort wiederherstellen", exact: true }).click(); await expect(page.getByText(/Dieser Wiederherstellungscode ist ungültig/)).toBeVisible(); await expect(page.getByLabel("Wiederherstellungscode", { exact: true })).toHaveValue(""); await expect(page.locator("body")).not.toContainText(recoveryCode); }); test("issuing a code requires independent identity verification and discards the one-time display on close", async ({ page }) => { const posts = await mockPasswordApi(page); await page.goto("/?password-lifecycle&mode=issue&language=en"); const issue = page.getByRole("button", { name: "Issue recovery code", exact: true }); await page.getByLabel("Current password", { exact: true }).fill(currentPassword); await expect(issue).toBeDisabled(); await page.getByRole("checkbox", { name: /I independently verified/ }).check(); await issue.click(); await expect(page.getByLabel("Recovery code", { exact: true })).toHaveValue(recoveryCode); expect(posts).toEqual([{ path: "/api/v1/auth/password/recovery/target-1", body: { current_password: currentPassword, identity_verified: true } }]); await expect(page.getByText(/Expires:/)).toBeVisible(); await expectNoSecretsInStorage(page); await page.getByRole("dialog").getByRole("button", { name: "Close", exact: true }).last().click(); await page.getByRole("button", { name: "Reopen recovery" }).click(); await expect(page.getByLabel("Recovery code", { exact: true })).toHaveCount(0); await expect(page.getByLabel("Current password", { exact: true })).toHaveValue(""); await expect(page.getByRole("checkbox", { name: /I independently verified/ })).not.toBeChecked(); }); test("System account recovery actions require a local interactive System owner and a local target", async ({ page }) => { await mockPasswordApi(page); await page.goto("/?password-lifecycle&mode=admin&language=en"); await expect(page.getByRole("button", { name: "Issue recovery code", exact: true })).toHaveCount(1); for (const query of ["owner=false", "external=true", "api-key=true"]) { await page.goto(`/?password-lifecycle&mode=admin&language=en&${query}`); await expect(page.getByText("local@example.test", { exact: true }).first()).toBeVisible(); await expect(page.getByRole("button", { name: "Issue recovery code", exact: true })).toHaveCount(0); } }); test("forgot-password link and recovery controls follow the disabled policy", async ({ page }) => { await mockPasswordApi(page, { enabled: false }); await page.goto("/?password-lifecycle&mode=login&language=en"); await expect(page.getByRole("dialog")).toBeVisible(); await expect(page.getByRole("link", { name: "Forgot your password?" })).toHaveCount(0); await page.goto("/?password-lifecycle&mode=recover&language=en"); await expect(page.getByText(/Administrator-assisted password recovery is not enabled/)).toBeVisible(); await expect(page.getByLabel("Recovery code", { exact: true })).toHaveCount(0); }); test("enabled forgot-password link navigates without credentials in its URL", async ({ page }) => { await mockPasswordApi(page); await page.goto("/?password-lifecycle&mode=login&language=en"); const link = page.getByRole("link", { name: "Forgot your password?" }); await expect(link).toHaveAttribute("href", "/password-recovery"); await link.click(); await expect(page).toHaveURL(/\/password-recovery$/); }); for (const [language, theme] of [["en", "light"], ["de", "light"], ["en", "dark"], ["de", "dark"]]) { test(`required password form is accessible on mobile in ${language} ${theme}`, async ({ page }, testInfo) => { await mockPasswordApi(page); await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`/?password-lifecycle&mode=required&language=${language}&theme=${theme}`); await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); await page.addScriptTag({ path: axePath }); const violations = await page.evaluate(async () => { const axe = (window as typeof window & { axe: { run: (options: unknown) => Promise<{ violations: Array<{ id: string; nodes: Array<{ target: unknown; failureSummary?: string }> }> }> } }).axe; const result = await axe.run({ runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21aa"] } }); return result.violations.map(({ id, nodes }) => ({ id, nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })) })); }); expect(violations).toEqual([]); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); await page.screenshot({ path: testInfo.outputPath(`password-required-${language}-${theme}.png`), fullPage: true }); }); }