feat(security): isolate bounded work and support required auth actions
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
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<string, unknown> }> = [];
|
||||
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<HTMLElement>("[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 });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user