From 9d77f6ad175891156d2fc202e4288375fcfe987b Mon Sep 17 00:00:00 2001 From: lilleman Date: Fri, 19 Jun 2026 19:28:17 +0200 Subject: [PATCH] =?UTF-8?q?=C2=A78=20full=20browser=20E2E=20(todo=20=C2=A7?= =?UTF-8?q?8);=20the=20real=20Playwright=20UI=20against=20the=20live=20sta?= =?UTF-8?q?ck=20=E2=80=94=20the=20browser-UI=20flows=20the=20earlier=20ful?= =?UTF-8?q?l-stack=20suites=20deferred=20here.=20New=20e2e/full-flow.spec.?= =?UTF-8?q?ts=20+=20compose.e2e-full.yml=20covering=20password=20login,=20?= =?UTF-8?q?mocked=20SSO,=20menu=20filtering=20by=20role,=20users/groups/ro?= =?UTF-8?q?les=20CRUD,=20a=20permission-gated=20plugin=20page,=20and=20log?= =?UTF-8?q?out=20(6/6=20green=20on=20a=20clean=20stack,=20then=20torn=20do?= =?UTF-8?q?wn).=20Same-origin=20gateway=20(e2e/proxy.mjs,=20stdlib=20rever?= =?UTF-8?q?se=20proxy)=20fronts=20web=20+=20Kratos=20on=20one=20host=20so?= =?UTF-8?q?=20the=20browser's=20cookies=20round-trip=20(the=20themed=20for?= =?UTF-8?q?m=20posts=20straight=20to=20Kratos);=20ory/kratos/e2e-proxy.yml?= =?UTF-8?q?=20points=20Kratos=20at=20it=20+=20--dev=20so=20cookies=20aren'?= =?UTF-8?q?t=20Secure=20over=20http.=20SSO=20backed=20by=20a=20stdlib=20mo?= =?UTF-8?q?ck=20OIDC=20provider=20(e2e/mock-oidc.mjs,=20RS256=20id=5Ftoken?= =?UTF-8?q?,=20nonce-bound=20codes).=20Found=20+=20fixed=20a=20real=20bug?= =?UTF-8?q?=20the=20E2E=20surfaced:=20the=20SSO=20submit=20button=20shares?= =?UTF-8?q?=20the=20form=20with=20the=20required=20email/password=20fields?= =?UTF-8?q?,=20so=20HTML5=20validation=20blocked=20it=20=E2=80=94=20added?= =?UTF-8?q?=20formnovalidate=20to=20the=20SSO=20buttons=20(auth-card.ejs),?= =?UTF-8?q?=20tests-first.=20Stability-reviewer=20APPROVE,=20no=20Critical?= =?UTF-8?q?/High=20(every=20dev/insecure=20knob=20is=20e2e-overlay-scoped,?= =?UTF-8?q?=20base/prod=20compose=20unaffected).=20typecheck=20+=20305=20u?= =?UTF-8?q?nits=20green.=20Also=20marks=20the=20=C2=A78=20E2E-harness=20it?= =?UTF-8?q?em=20(full=20stack=20up=20+=20seeded=20admin/Keto=20roles=20+?= =?UTF-8?q?=20tear-down).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 ++++- compose.e2e-full.yml | 101 +++++++++++++++++++++++++++++++ e2e/full-flow.spec.ts | 114 +++++++++++++++++++++++++++++++++++ e2e/mock-oidc.mjs | 76 +++++++++++++++++++++++ e2e/proxy.mjs | 24 ++++++++ ory/kratos/e2e-proxy.yml | 40 ++++++++++++ src/app.test.ts | 5 +- src/auth-card.test.ts | 5 +- todo.md | 6 +- views/partials/auth-card.ejs | 2 +- 10 files changed, 378 insertions(+), 9 deletions(-) create mode 100644 compose.e2e-full.yml create mode 100644 e2e/full-flow.spec.ts create mode 100644 e2e/mock-oidc.mjs create mode 100644 e2e/proxy.mjs create mode 100644 ory/kratos/e2e-proxy.yml diff --git a/README.md b/README.md index 1e4639e..20ea436 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ otherwise drags up its `depends_on` services. ### End-to-end (Playwright) E2E runs in the official Playwright image (browsers preinstalled) against the live `web` -service — no Node/browsers on the host. There are two suites: +service — no Node/browsers on the host. There are four suites: **Visual + design system** (`visual.spec.ts`) — Ory-free (mock-data dashboard), so it stays fast. It screenshots the live pages **and** the `html-css-foundation` mockups, then asserts the live DOM @@ -253,6 +253,18 @@ docker compose -f compose.yml -f compose.e2e-oauth.yml run --build --rm e2e # docker compose -f compose.yml -f compose.e2e-oauth.yml down -v # tear down after ``` +**Full browser flow** (`full-flow.spec.ts`) — the real Playwright UI against the live stack: the +themed **password login** and a **mocked-SSO** login (an in-network mock OIDC provider, +`e2e/mock-oidc.mjs`), **menu filtering by role**, the **users/groups/roles** admin CRUD, a +permission-gated **plugin page**, and **logout**. Because the themed form posts straight to Kratos +and cookies are host-scoped, a tiny same-origin gateway (`e2e/proxy.mjs`) fronts web + Kratos on one +host (`ory/kratos/e2e-proxy.yml` points Kratos at it) — exactly as a production reverse proxy would. + +```bash +docker compose -f compose.yml -f compose.e2e-full.yml run --build --rm e2e # run the suite +docker compose -f compose.yml -f compose.e2e-full.yml down -v # tear down after +``` + `--build` rebuilds the runner so spec edits are always picked up (the image bakes in `e2e/`). Screenshots + an HTML report land in `e2e/artifacts/` (git-ignored). Every user-facing flow diff --git a/compose.e2e-full.yml b/compose.e2e-full.yml new file mode 100644 index 0000000..033e99b --- /dev/null +++ b/compose.e2e-full.yml @@ -0,0 +1,101 @@ +# Full browser E2E (todo §8) — the real Playwright UI flow against the live stack: password + +# mocked-SSO login, menu filtering by role, users/groups/roles CRUD, a plugin page, logout. A tiny +# same-origin gateway (proxy, e2e/proxy.mjs) fronts web + Kratos on one host so the browser's cookies +# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test. +# docker compose -f compose.yml -f compose.e2e-full.yml run --build --rm e2e +# docker compose -f compose.yml -f compose.e2e-full.yml down -v # tear down after +services: + web: + # First-party + SSO flows need Kratos + Keto + bootstrap, not Hydra — drop it so the stack is + # leaner. SSO is enabled here only (clean clone stays password-only): the mock provider's whole + # array is the env-settable form Kratos offers, mapped through the committed claims jsonnet. + depends_on: !override + bootstrap: + condition: service_completed_successfully + kratos: + condition: service_healthy + keto: + condition: service_healthy + shifts-upstream: + condition: service_healthy + environment: + CACHE_TEMPLATES: "true" + REQUIRE_SECURE_SECRETS: "false" + SECURE_COOKIES: "false" # the browser hits the gateway over http — Secure cookies wouldn't be stored + healthcheck: + test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/"] + interval: 2s + timeout: 4s + retries: 30 + + # Browser-facing URLs (base_url, every ui_url, the after-login redirect) move to the gateway host. + # `--dev`: the browser hits the gateway over http, but Kratos marks cookies Secure for a + # non-loopback host like `proxy` — dev mode drops that so the session/CSRF cookies are stored. + kratos: + command: serve --dev -c /etc/config/kratos/kratos.yml -c /etc/config/kratos/e2e-proxy.yml --watch-courier + environment: + SELFSERVICE_METHODS_OIDC_ENABLED: "true" + SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS: >- + [{"id":"mock","provider":"generic","label":"Mock SSO","client_id":"plainpages-e2e","client_secret":"e2e-secret","issuer_url":"http://mock-oidc:9000","scope":["openid","email"],"mapper_url":"file:///etc/config/kratos/oidc/claims.jsonnet"}] + + # The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows. + shifts-upstream: + image: node:24.16.0-alpine3.24 + command: ["node", "/server.mjs"] + volumes: + - ./examples/shifts-upstream/server.mjs:/server.mjs:ro + healthcheck: + test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:4000/shifts"] + interval: 2s + timeout: 4s + retries: 15 + + # Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos + # verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos. + mock-oidc: + image: node:24.16.0-alpine3.24 + command: ["node", "/mock-oidc.mjs"] + environment: + ISSUER: http://mock-oidc:9000 + SSO_EMAIL: sso-user@plainpages.local + volumes: + - ./e2e/mock-oidc.mjs:/mock-oidc.mjs:ro + healthcheck: + test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9000/.well-known/openid-configuration"] + interval: 2s + timeout: 4s + retries: 15 + + # Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e/proxy.mjs). + proxy: + image: node:24.16.0-alpine3.24 + command: ["node", "/proxy.mjs"] + depends_on: + web: + condition: service_healthy + environment: + KRATOS_URL: http://kratos:4433 + WEB_URL: http://web:3000 + volumes: + - ./e2e/proxy.mjs:/proxy.mjs:ro + healthcheck: + test: ["CMD", "wget", "-q", "-O", "-", "http://localhost/"] + interval: 2s + timeout: 4s + retries: 30 + + e2e: + build: + context: . + dockerfile: Dockerfile.e2e + command: ["npx", "playwright", "test", "full-flow.spec.ts"] + depends_on: + mock-oidc: + condition: service_healthy + proxy: + condition: service_healthy + environment: + BASE_URL: http://proxy + KRATOS_ADMIN_URL: http://kratos:4434 + volumes: + - ./e2e/artifacts:/e2e/artifacts diff --git a/e2e/full-flow.spec.ts b/e2e/full-flow.spec.ts new file mode 100644 index 0000000..e542f23 --- /dev/null +++ b/e2e/full-flow.spec.ts @@ -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 { + 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); +}); diff --git a/e2e/mock-oidc.mjs b/e2e/mock-oidc.mjs new file mode 100644 index 0000000..9a8f32a --- /dev/null +++ b/e2e/mock-oidc.mjs @@ -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})`)); diff --git a/e2e/proxy.mjs b/e2e/proxy.mjs new file mode 100644 index 0000000..40fdbbb --- /dev/null +++ b/e2e/proxy.mjs @@ -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}`)); diff --git a/ory/kratos/e2e-proxy.yml b/ory/kratos/e2e-proxy.yml new file mode 100644 index 0000000..f0aed22 --- /dev/null +++ b/ory/kratos/e2e-proxy.yml @@ -0,0 +1,40 @@ +# Browser-E2E overlay (compose.e2e-full.yml) — merged after kratos.yml via a second `-c`. The +# full-flow suite drives the real browser, so web + Kratos must share one origin (the `proxy` +# gateway, e2e/proxy.mjs). Point Kratos' public base_url and every self-service URL at that host so +# the flow action, the session cookie, and the after-login redirect all stay same-origin as the +# browser sees them. The normal (10m) tokenizer TTL from kratos.yml is kept — no re-mint mid-test. +serve: + public: + base_url: http://proxy/ + +selfservice: + default_browser_return_url: http://proxy/ + allowed_return_urls: + - http://proxy + flows: + error: + ui_url: http://proxy/error + login: + ui_url: http://proxy/login + after: + default_browser_return_url: http://proxy/auth/complete + registration: + ui_url: http://proxy/registration + after: + # First SSO login auto-registers the identity: log it in (session) and route through our + # completion route so the JWT is minted, same as a password login. + default_browser_return_url: http://proxy/auth/complete + oidc: + hooks: + - hook: session + settings: + ui_url: http://proxy/settings + recovery: + ui_url: http://proxy/recovery + verification: + ui_url: http://proxy/verification + after: + default_browser_return_url: http://proxy/ + logout: + after: + default_browser_return_url: http://proxy/login diff --git a/src/app.test.ts b/src/app.test.ts index 3383deb..facfe20 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -429,9 +429,10 @@ test("renders a fetched flow as the themed auth page: fields post straight to Kr assert.match(html, /name="password"[^>]*type="password"/); assert.match(html, /<% } %><% }) %> +
<%= sso.divider || "or" %>
<% } -%>