§8 full browser E2E (todo §8); the real Playwright UI against the live stack — the browser-UI flows the earlier full-stack suites deferred here. New e2e/full-flow.spec.ts + compose.e2e-full.yml covering password login, mocked SSO, menu filtering by role, users/groups/roles CRUD, a permission-gated plugin page, and logout (6/6 green on a clean stack, then torn down). Same-origin gateway (e2e/proxy.mjs, stdlib reverse proxy) fronts web + Kratos on one host so the browser's cookies round-trip (the themed form posts straight to Kratos); ory/kratos/e2e-proxy.yml points Kratos at it + --dev so cookies aren't Secure over http. SSO backed by a stdlib mock OIDC provider (e2e/mock-oidc.mjs, RS256 id_token, nonce-bound codes). Found + fixed a real bug the E2E surfaced: the SSO submit button shares the form with the required email/password fields, so HTML5 validation blocked it — added formnovalidate to the SSO buttons (auth-card.ejs), tests-first. Stability-reviewer APPROVE, no Critical/High (every dev/insecure knob is e2e-overlay-scoped, base/prod compose unaffected). typecheck + 305 units green. Also marks the §8 E2E-harness item (full stack up + seeded admin/Keto roles + tear-down).

This commit is contained in:
2026-06-19 19:28:17 +02:00
parent 1961a4c163
commit 9d77f6ad17
10 changed files with 378 additions and 9 deletions

114
e2e/full-flow.spec.ts Normal file
View File

@@ -0,0 +1,114 @@
import { type Browser, type Page, expect, test } from "@playwright/test";
import { randomUUID } from "node:crypto";
// Full browser E2E (todo §8): the real Playwright UI against the live stack via the same-origin
// gateway (compose.e2e-full.yml). Covers password + mocked-SSO login, menu filtering by role, the
// users/groups/roles admin CRUD, a permission-gated plugin page, and logout. The earlier full-stack
// suites drove flows over HTTP and deferred the browser-UI login here; this is that coverage.
//
// Runs on a fresh stack (`down -v` after, like the other full-stack suites). The serial admin
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
// (no cross-group shared backend writes) or serialise the file if that ever changes.
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3), holds the admin role in Keto
const ADMIN_PASSWORD = "admin";
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
// Drive the themed password login form → Kratos → /auth/complete → dashboard, signed in.
async function loginPassword(page: Page): Promise<void> {
await page.goto("/login");
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
await page.fill('input[name="password"]', ADMIN_PASSWORD);
await page.locator('.auth-form button[type="submit"]').click();
await expect(page.locator(".profile-mail")).toHaveText(ADMIN_EMAIL); // waits through the redirect chain
}
test.describe.serial("authenticated admin journey", () => {
let browser: Browser;
let page: Page;
test.beforeAll(async ({ browser: b }) => {
browser = b;
page = await (await browser.newContext()).newPage();
test.setTimeout(90_000);
await loginPassword(page);
});
test.afterAll(async () => { await page.context().close(); });
test("menu filters by role: an admin sees the gated Admin section + the plugin", async () => {
// The signed-in admin holds admin + scheduling:read/write, so both gated sections are present
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
await page.goto("/");
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1);
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1);
});
test("users CRUD: create a user, see it listed, then delete it via the confirm step", async () => {
const email = `e2e-${suffix}@plainpages.local`;
await page.goto("/admin/users/new");
await page.fill('input[name="email"]', email);
await page.fill('input[name="first"]', "E2E");
await page.fill('input[name="last"]', "User");
await page.locator('.form-card button[type="submit"]').click();
await expect(page).toHaveURL(/\/admin\/users(\?|$)/); // PRG back to the list
const row = page.locator("tr", { hasText: email });
await expect(row).toBeVisible();
// Delete through the confirm interstitial (the row's Edit link carries the id).
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
await page.goto(`${editHref}/delete`);
await page.getByRole("button", { name: "Delete user" }).click(); // the confirm form's danger button
await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
});
test("groups + roles CRUD: create one of each (writes go to Keto) and see them listed", async () => {
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form
// enforces it); pick the first option (a user) from the required picker.
const group = `e2e-grp-${suffix}`;
await page.goto("/admin/groups/new");
await page.fill('input[name="name"]', group);
await page.locator('select[name="member"]').selectOption({ index: 1 });
await page.locator('.form-card button[type="submit"]').click();
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
await expect(page.locator("main")).toContainText(group);
const role = `e2e-role-${suffix}`;
await page.goto("/admin/roles/new");
await page.fill('input[name="name"]', role);
await page.locator('select[name="member"]').selectOption({ index: 1 });
await page.locator('.form-card button[type="submit"]').click();
await expect(page).toHaveURL(/\/admin\/roles(\?|\/|$)/);
await expect(page.locator("main")).toContainText(role);
});
test("plugin page: the reference plugin renders its upstream shifts inside the native shell", async () => {
await page.goto("/scheduling/shifts");
await expect(page.locator("h1")).toHaveText("Shifts");
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
});
test("logout: signing out ends the session and returns to the login page", async () => {
await page.goto("/");
await page.locator("summary.profile").click(); // open the profile dropdown
await page.locator('form[action="/logout"] button[type="submit"]').click();
await page.waitForURL(/\/login(\?|$)/);
// The session is gone: the dashboard no longer shows the admin nav.
await page.goto("/");
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
});
});
test("mocked SSO login: the provider button signs a user in via OIDC", async ({ page }) => {
test.setTimeout(90_000);
await page.goto("/login");
await expect(page.locator(".sso-btn")).toBeVisible(); // the configured provider renders a button
await page.locator(".sso-btn").click();
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
// A fresh SSO identity holds no roles, so the gated Admin section stays hidden.
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
});

76
e2e/mock-oidc.mjs Normal file
View File

@@ -0,0 +1,76 @@
// Mock OIDC provider for the SSO browser E2E (todo §8) — a stand-in for Google/etc. so the test
// never leaves the compose network. Auto-approves /authorize (no provider login UI), then signs an
// RS256 id_token Kratos verifies against /jwks. stdlib only, in-memory, NOT app code. The single
// host (mock-oidc:9000) is reachable by both the browser (/authorize) and Kratos (token/jwks).
import { createServer } from "node:http";
import { createSign, generateKeyPairSync, randomUUID } from "node:crypto";
const ISSUER = process.env.ISSUER ?? "http://mock-oidc:9000";
const CLIENT_ID = process.env.CLIENT_ID ?? "plainpages-e2e";
const EMAIL = process.env.SSO_EMAIL ?? "sso-user@plainpages.local";
const PORT = Number(process.env.PORT ?? 9000);
const KID = "mock-1";
// One signing key for the process; its public half is published at /jwks for Kratos to verify with.
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const jwk = { ...publicKey.export({ format: "jwk" }), alg: "RS256", kid: KID, use: "sig" };
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
function idToken(nonce) {
const now = Math.floor(Date.now() / 1000);
const header = b64({ alg: "RS256", kid: KID, typ: "JWT" });
const payload = b64({
aud: CLIENT_ID, email: EMAIL, email_verified: true, exp: now + 600, family_name: "User",
given_name: "SSO", iat: now, iss: ISSUER, name: "SSO User", nonce, sub: "mock-subject-1",
});
const sig = createSign("RSA-SHA256").update(`${header}.${payload}`).end().sign(privateKey).toString("base64url");
return `${header}.${payload}.${sig}`;
}
const codes = new Map(); // single-use auth code → the nonce Kratos sent (echoed into the id_token)
const json = (res, body) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
createServer((req, res) => {
const url = new URL(req.url ?? "/", ISSUER);
const p = url.pathname;
if (p === "/.well-known/openid-configuration") {
return json(res, {
authorization_endpoint: `${ISSUER}/authorize`, id_token_signing_alg_values_supported: ["RS256"],
issuer: ISSUER, jwks_uri: `${ISSUER}/jwks`, response_types_supported: ["code"],
scopes_supported: ["openid", "email"], subject_types_supported: ["public"],
token_endpoint: `${ISSUER}/token`, userinfo_endpoint: `${ISSUER}/userinfo`,
});
}
if (p === "/jwks") return json(res, { keys: [jwk] });
// Auto-approve: no login screen — mint a code bound to Kratos' nonce and bounce to the redirect_uri.
if (p === "/authorize") {
const code = randomUUID();
codes.set(code, url.searchParams.get("nonce") ?? "");
const back = new URL(url.searchParams.get("redirect_uri") ?? `${ISSUER}/`);
back.searchParams.set("code", code);
const state = url.searchParams.get("state");
if (state) back.searchParams.set("state", state);
res.writeHead(302, { location: back.toString() }).end();
return;
}
if (p === "/token" && req.method === "POST") {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
const code = new URLSearchParams(body).get("code") ?? "";
const nonce = codes.get(code) ?? "";
codes.delete(code);
json(res, { access_token: randomUUID(), expires_in: 600, id_token: idToken(nonce), token_type: "Bearer" });
});
return;
}
if (p === "/userinfo") {
return json(res, { email: EMAIL, email_verified: true, family_name: "User", given_name: "SSO", name: "SSO User", sub: "mock-subject-1" });
}
res.writeHead(404, { "content-type": "text/plain" }).end("not found");
}).listen(PORT, () => console.log(`mock-oidc on :${PORT} (issuer ${ISSUER})`));

24
e2e/proxy.mjs Normal file
View File

@@ -0,0 +1,24 @@
// Same-origin gateway for the browser E2E (todo §8). The themed login form posts straight to
// Kratos' flow action and Kratos sets the session cookie for its own base_url host — so for a real
// browser, web and Kratos must look like ONE origin (cookies are host-scoped). This tiny stdlib
// reverse proxy fronts both on a single host (the browser's only origin), exactly as a production
// reverse proxy would: Kratos-owned paths → kratos, everything else → web. NOT app code; dev/test only.
import { createServer, request } from "node:http";
const WEB = new URL(process.env.WEB_URL ?? "http://web:3000");
const KRATOS = new URL(process.env.KRATOS_URL ?? "http://kratos:4433");
const PORT = Number(process.env.PORT ?? 80);
// Kratos public owns these prefixes (self-service flows, sessions, its well-known/schemas); the
// browser hits them via the flow action + OIDC callbacks. Everything else is the web app.
const toKratos = (path) => ["/self-service", "/sessions", "/.well-known/ory", "/schemas"].some((p) => path === p || path.startsWith(`${p}/`));
createServer((req, res) => {
const target = toKratos(req.url ?? "/") ? KRATOS : WEB;
const upstream = request(
{ headers: req.headers, host: target.hostname, method: req.method, path: req.url, port: target.port },
(up) => { res.writeHead(up.statusCode ?? 502, up.headers); up.pipe(res); },
);
upstream.on("error", (err) => { res.writeHead(502, { "content-type": "text/plain" }).end(`gateway: ${err.message}`); });
req.pipe(upstream);
}).listen(PORT, () => console.log(`e2e gateway on :${PORT} → web ${WEB.host} / kratos ${KRATOS.host}`));