Consolidate E2E into e2e-tests/ (Dockerfile + compose.{visual,auth,oauth,full,devstack}.yml, WORKDIR /e2e-tests); move ci.sh to repo root
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
|
||||
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
|
||||
FROM mcr.microsoft.com/playwright:v1.49.1-noble
|
||||
|
||||
WORKDIR /e2e-tests
|
||||
|
||||
COPY e2e-tests/package.json e2e-tests/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY e2e-tests/ ./
|
||||
|
||||
CMD ["npx", "playwright", "test"]
|
||||
@@ -0,0 +1,103 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// Full-stack auth E2E: token timeout + silent re-mint ("stay signed in"). Runs against the
|
||||
// real Ory stack via e2e-tests/compose.auth.yml, where the session→JWT TTL is shortened to 8s and the
|
||||
// web clock skew is 0 — so the ~10m token lapses in seconds and the hot path re-mints it from the
|
||||
// still-live Kratos session. We drive the flow over HTTP (fetch, manual cookies) because Kratos
|
||||
// and web sit on different hosts here; web's own server-side cookie relay is what we exercise.
|
||||
// The browser-UI login is owned by the full-flow E2E; this proves the timeout/refresh server behaviour end-to-end.
|
||||
const WEB = process.env.BASE_URL ?? "http://web:3000";
|
||||
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
||||
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin role granted in Keto
|
||||
const ADMIN_PASSWORD = "admin";
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// The full Set-Cookie line for `name`, or undefined. (cleared cookies carry Max-Age=0 + empty value.)
|
||||
function setCookieLine(res: Response, name: string): string | undefined {
|
||||
return res.headers.getSetCookie().find((c) => c.startsWith(`${name}=`));
|
||||
}
|
||||
function cookieValue(line: string): string {
|
||||
return line.split(";", 1)[0]!.slice(line.indexOf("=") + 1);
|
||||
}
|
||||
// Build a "name=value; …" Cookie header from a response's Set-Cookie lines (skips cleared ones).
|
||||
function relayCookies(res: Response): string {
|
||||
return res.headers
|
||||
.getSetCookie()
|
||||
.map((c) => c.split(";", 1)[0]!)
|
||||
.filter((kv) => kv.split("=")[1] !== "")
|
||||
.join("; ");
|
||||
}
|
||||
// Read a JWT's claims without verifying (web already verified it; we only inspect exp/roles).
|
||||
function jwtClaims(jwt: string): { email: string; exp: number; roles: string[]; sub: string } {
|
||||
return JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString());
|
||||
}
|
||||
|
||||
// Authenticate the seeded admin via Kratos' browser login flow (JSON), return its session cookie value.
|
||||
async function kratosLogin(): Promise<string> {
|
||||
const init = await fetch(`${KRATOS}/self-service/login/browser`, { headers: { accept: "application/json" } });
|
||||
expect(init.ok, `init login flow: ${init.status}`).toBeTruthy();
|
||||
const flow = await init.json();
|
||||
const csrf = flow.ui.nodes.find((n: { attributes?: { name?: string } }) => n.attributes?.name === "csrf_token");
|
||||
const submit = await fetch(flow.ui.action, {
|
||||
body: JSON.stringify({ csrf_token: csrf?.attributes?.value ?? "", identifier: ADMIN_EMAIL, method: "password", password: ADMIN_PASSWORD }),
|
||||
headers: { accept: "application/json", "content-type": "application/json", cookie: relayCookies(init) },
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(submit.status, `login submit: ${await submit.text()}`).toBe(200);
|
||||
const session = setCookieLine(submit, "plainpages_session");
|
||||
expect(session, "Kratos sets the session cookie on login").toBeTruthy();
|
||||
return cookieValue(session!);
|
||||
}
|
||||
|
||||
// Hit web's home with the given cookies (no redirect-follow so we can read its Set-Cookie).
|
||||
function hitWeb(session: string, jwt: string): Promise<Response> {
|
||||
return fetch(`${WEB}/`, { headers: { cookie: `plainpages_session=${session}; plainpages_jwt=${jwt}` }, redirect: "manual" });
|
||||
}
|
||||
|
||||
// Poll web until it (re-)sets plainpages_jwt — i.e. the token has lapsed and the hot path acted:
|
||||
// a live session re-mints a fresh token; a dead one clears the cookie.
|
||||
async function awaitJwtSetCookie(session: string, jwt: string): Promise<string> {
|
||||
const deadline = Date.now() + 20_000;
|
||||
while (Date.now() < deadline) {
|
||||
const line = setCookieLine(await hitWeb(session, jwt), "plainpages_jwt");
|
||||
if (line) return line;
|
||||
await sleep(1000);
|
||||
}
|
||||
throw new Error("timed out waiting for web to act on the expired token");
|
||||
}
|
||||
|
||||
test("an expired session JWT is silently re-minted while Kratos lives, then cleared once it dies", async () => {
|
||||
test.setTimeout(90_000); // two short-TTL windows (8s each) + Ory round-trips
|
||||
|
||||
// 1. Log in for real, then complete login on web → our session JWT (roles read from Keto).
|
||||
const session = await kratosLogin();
|
||||
const complete = await fetch(`${WEB}/auth/complete`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
|
||||
expect(complete.status, "auth/complete redirects home").toBe(303);
|
||||
const jwt1Line = setCookieLine(complete, "plainpages_jwt");
|
||||
expect(jwt1Line, "auth/complete sets our session JWT").toBeTruthy();
|
||||
const jwt1 = cookieValue(jwt1Line!);
|
||||
|
||||
const claims1 = jwtClaims(jwt1);
|
||||
expect(claims1.email).toBe(ADMIN_EMAIL);
|
||||
expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy();
|
||||
expect(claims1.roles, "roles are projected from Keto").toContain("admin");
|
||||
|
||||
// 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT.
|
||||
const jwt2Line = await awaitJwtSetCookie(session, jwt1);
|
||||
const jwt2 = cookieValue(jwt2Line!);
|
||||
expect(jwt2, "a different token was minted").not.toBe(jwt1);
|
||||
const claims2 = jwtClaims(jwt2);
|
||||
expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp);
|
||||
expect(claims2.roles, "re-mint re-reads roles from Keto").toContain("admin");
|
||||
|
||||
// 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared.
|
||||
const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" });
|
||||
expect([204, 404]).toContain(revoke.status);
|
||||
|
||||
const clearedLine = await awaitJwtSetCookie(session, jwt2);
|
||||
expect(cookieValue(clearedLine), "the stale JWT cookie is emptied").toBe("");
|
||||
expect(clearedLine, "and expired (Max-Age=0)").toMatch(/Max-Age=0/i);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
# Full-stack auth E2E — token timeout + silent re-mint ("stay signed in"). The Ory-free
|
||||
# visual suite (e2e-tests/compose.visual.yml) covers the design system; this is its full-stack counterpart:
|
||||
# real Postgres + Kratos + Keto + bootstrap + web, with a SHORT tokenizer TTL (ory/kratos/e2e.yml)
|
||||
# and zero clock skew, so the JWT lapses and re-mints within seconds instead of ~10m.
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.auth.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.auth.yml down -v # tear down after
|
||||
services:
|
||||
web:
|
||||
# This suite exercises only the Kratos session → JWT re-mint; it needs Kratos + Keto + bootstrap,
|
||||
# not Hydra. Drop the base web→hydra dep so the leaner stack doesn't boot Hydra (which the e2e
|
||||
# overlays don't run with --dev, so it would refuse its http issuer and never become healthy).
|
||||
depends_on: !override
|
||||
bootstrap:
|
||||
condition: service_completed_successfully
|
||||
kratos:
|
||||
condition: service_healthy
|
||||
keto:
|
||||
condition: service_healthy
|
||||
# Dev throwaways are fine for the test stack; the runner hits web over http; treat the JWT as
|
||||
# expired the instant its TTL lapses (no 60s leeway) so the re-mint fires promptly.
|
||||
environment:
|
||||
APP_URL: http://web:3000 # the runner calls web on this host → canonical-host redirect stays inert
|
||||
CACHE_TEMPLATES: "true"
|
||||
JWT_CLOCK_SKEW_SEC: "0"
|
||||
REQUIRE_SECURE_SECRETS: "false"
|
||||
SECURE_COOKIES: "false"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"]
|
||||
interval: 2s
|
||||
timeout: 4s
|
||||
retries: 30
|
||||
|
||||
# Shorten the session→JWT TTL and expose a network-resolvable base_url (ory/kratos/e2e.yml),
|
||||
# merged after the base config.
|
||||
kratos:
|
||||
command: serve -c /etc/config/kratos/kratos.yml -c /etc/config/kratos/e2e.yml --watch-courier
|
||||
|
||||
e2e:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: e2e-tests/Dockerfile
|
||||
depends_on:
|
||||
web:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BASE_URL: http://web:3000
|
||||
KRATOS_ADMIN_URL: http://kratos:4434
|
||||
KRATOS_PUBLIC_URL: http://kratos:4433
|
||||
command: ["npx", "playwright", "test", "auth-refresh.spec.ts"]
|
||||
volumes:
|
||||
- ./e2e-tests/artifacts:/e2e-tests/artifacts
|
||||
@@ -0,0 +1,48 @@
|
||||
# Dev-stack login regression — guards the from-scratch experience the banner advertises (login from
|
||||
# http://localhost:3000 works, and entering on 127.0.0.1 is canonicalised). Unlike the proxied
|
||||
# full-flow suite (which fronts web + Kratos on ONE origin and so can't see this class of bug), this
|
||||
# runs against the *plain* `docker compose up` topology and drives the browser on the HOST network, so
|
||||
# it sees http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host
|
||||
# browser does. Merge the dev override so the live stack is byte-for-byte `docker compose up`:
|
||||
# docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml down -v # tear down
|
||||
services:
|
||||
web:
|
||||
# Pin APP_URL so the regression is deterministic regardless of any APP_URL exported in the shell
|
||||
# running ci.sh (overrides the dev override's ${APP_URL:-…}); the runner's BASE_URL matches it.
|
||||
environment:
|
||||
APP_URL: http://localhost:3000
|
||||
# Base web has no healthcheck; add one so the runner waits for a ready app (deps come via base).
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"]
|
||||
interval: 2s
|
||||
timeout: 4s
|
||||
retries: 30
|
||||
|
||||
# Pin Kratos' browser URLs to localhost too (literal, not ${APP_URL}) so the whole suite is
|
||||
# hermetic — the 127.0.0.1 sub-test asserts canonicalisation onto localhost, which only holds if
|
||||
# web AND Kratos agree on localhost regardless of the ambient shell env.
|
||||
kratos:
|
||||
environment:
|
||||
SERVE_PUBLIC_BASE_URL: http://localhost:4433/
|
||||
SELFSERVICE_DEFAULT_BROWSER_RETURN_URL: http://localhost:3000/
|
||||
SELFSERVICE_ALLOWED_RETURN_URLS: http://localhost:3000
|
||||
SELFSERVICE_FLOWS_ERROR_UI_URL: http://localhost:3000/error
|
||||
SELFSERVICE_FLOWS_LOGIN_UI_URL: http://localhost:3000/login
|
||||
SELFSERVICE_FLOWS_LOGIN_AFTER_DEFAULT_BROWSER_RETURN_URL: http://localhost:3000/auth/complete
|
||||
|
||||
e2e:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: e2e-tests/Dockerfile
|
||||
command: ["npx", "playwright", "test", "devstack-login.spec.ts"]
|
||||
# Host network: reach the host-published ports (web 3000, Kratos public 4433) at the very
|
||||
# hostnames a user types — localhost / 127.0.0.1 — so the cross-host CSRF-cookie split reproduces.
|
||||
network_mode: "host"
|
||||
depends_on:
|
||||
web:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BASE_URL: http://localhost:3000
|
||||
volumes:
|
||||
- ./e2e-tests/artifacts:/e2e-tests/artifacts
|
||||
@@ -0,0 +1,102 @@
|
||||
# Full browser E2E — 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-tests/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 e2e-tests/compose.full.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.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:
|
||||
APP_URL: http://proxy # the browser reaches web through the same-origin gateway → canonical-host redirect stays inert
|
||||
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/public/css/styles.css"]
|
||||
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-tests/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-tests/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-tests/proxy.mjs:/proxy.mjs:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost/public/css/styles.css"]
|
||||
interval: 2s
|
||||
timeout: 4s
|
||||
retries: 30
|
||||
|
||||
e2e:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: e2e-tests/Dockerfile
|
||||
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-tests/artifacts:/e2e-tests/artifacts
|
||||
@@ -0,0 +1,46 @@
|
||||
# Full-stack OAuth2 E2E — the login-challenge handler. Another app logs in *through* us:
|
||||
# Hydra starts an authorization flow and hands the browser to web's /oauth2/login; web resolves
|
||||
# it via the Kratos session and accepts. Runs against the real stack (Postgres + Kratos + Keto +
|
||||
# Hydra + bootstrap + web). The runner drives the flow over HTTP (fetch, manual cookies), so it
|
||||
# reaches the Ory services by their compose-network names.
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.oauth.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.oauth.yml down -v # tear down after
|
||||
services:
|
||||
web:
|
||||
# Dev throwaways are fine for the test stack; the runner hits web over http.
|
||||
environment:
|
||||
APP_URL: http://web:3000 # the runner/browser reach web on this host → canonical-host redirect stays inert
|
||||
CACHE_TEMPLATES: "true"
|
||||
REQUIRE_SECURE_SECRETS: "false"
|
||||
SECURE_COOKIES: "false"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"]
|
||||
interval: 2s
|
||||
timeout: 4s
|
||||
retries: 30
|
||||
|
||||
# --dev permits the http issuer (the base file drops it for an https prod issuer).
|
||||
hydra:
|
||||
command: serve all --dev -c /etc/config/hydra/hydra.yml
|
||||
|
||||
# Point the public base_url at the compose-network host so the runner can drive the Kratos
|
||||
# login flow over `kratos:4433` (kratos.yml's default 127.0.0.1 base_url only resolves host-side).
|
||||
kratos:
|
||||
environment:
|
||||
SERVE_PUBLIC_BASE_URL: http://kratos:4433/
|
||||
|
||||
e2e:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: e2e-tests/Dockerfile
|
||||
depends_on:
|
||||
web:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BASE_URL: http://web:3000
|
||||
HYDRA_ADMIN_URL: http://hydra:4445
|
||||
HYDRA_PUBLIC_URL: http://hydra:4444
|
||||
KRATOS_PUBLIC_URL: http://kratos:4433
|
||||
command: ["npx", "playwright", "test", "oauth-login.spec.ts"]
|
||||
volumes:
|
||||
- ./e2e-tests/artifacts:/e2e-tests/artifacts
|
||||
@@ -0,0 +1,39 @@
|
||||
# Playwright E2E. Brings up the app + a Playwright runner and exercises the live pages (design
|
||||
# system, theme switch, mobile layout, CSRF, landing, 404, plugin gating) — Ory-free, so it's fast.
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.visual.yml down -v # tear down after
|
||||
# --build rebuilds the runner (the image bakes in e2e-tests/) so spec edits are picked up.
|
||||
# Screenshots + HTML report land in ./e2e-tests/artifacts/ (git-ignored).
|
||||
services:
|
||||
web:
|
||||
# The dashboard renders mock data — no Ory needed. Drop the base file's kratos/keto
|
||||
# dependency so the visual suite stays fast and doesn't boot Postgres + the Ory stack.
|
||||
depends_on: !reset []
|
||||
# Dev throwaways are fine for tests; cache templates for production-like rendering.
|
||||
environment:
|
||||
APP_URL: http://web:3000 # the suite reaches web on this host → canonical-host redirect stays inert
|
||||
CACHE_TEMPLATES: "true"
|
||||
REQUIRE_SECURE_SECRETS: "false"
|
||||
SECURE_COOKIES: "false" # the suite hits web over http — Secure cookies wouldn't be stored
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"]
|
||||
interval: 2s
|
||||
timeout: 4s
|
||||
retries: 15
|
||||
|
||||
e2e:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: e2e-tests/Dockerfile
|
||||
# Just the Ory-free visual suite; the full-stack auth spec runs via e2e-tests/compose.auth.yml.
|
||||
command: ["npx", "playwright", "test", "visual.spec.ts"]
|
||||
depends_on:
|
||||
web:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BASE_URL: http://web:3000
|
||||
volumes:
|
||||
# The committed dev tokenizer key — the spec signs a session JWT with it so the gated
|
||||
# dashboard renders; web verifies it with the same key (the file it mounts read-only).
|
||||
- ./ory/kratos/tokenizer/jwks.json:/repo/jwks.json:ro
|
||||
- ./e2e-tests/artifacts:/e2e-tests/artifacts
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// Regression: the from-scratch dev experience the README/banner advertises must work. `docker compose
|
||||
// up`, open the printed login URL (http://localhost:3000), sign in as the seeded admin → you land on
|
||||
// the dashboard, signed in. Originally this dumped the user on http://127.0.0.1:3000/error?id=…
|
||||
// ("Page not found"): the banner printed `localhost` but kratos.yml hard-coded `127.0.0.1`, and a
|
||||
// host-scoped Kratos CSRF cookie can't cross `localhost`↔`127.0.0.1`, so the cross-host login POST
|
||||
// lost it and Kratos redirected to its error sink.
|
||||
//
|
||||
// The fix makes APP_URL the single source for the public host: the web app canonicalises every
|
||||
// off-host visitor onto it (so localhost / 127.0.0.1 / any alias funnel to one cookie host), Kratos'
|
||||
// browser URLs derive from it, and a real /error page replaces the 404.
|
||||
//
|
||||
// This is faithful to the user's environment: the runner uses the host network
|
||||
// (e2e-tests/compose.devstack.yml) against the plain `docker compose up` topology, so it sees
|
||||
// http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host browser
|
||||
// does. The proxied full-flow suite can't catch this regression — it fronts web + Kratos on one origin.
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
|
||||
const ADMIN_PASSWORD = "admin";
|
||||
|
||||
async function signIn(page: import("@playwright/test").Page): Promise<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
test("seeded admin logs in from the advertised URL (http://localhost:3000) and reaches the dashboard", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
// Open the app at the URL the first-run banner prints, then follow its "Log in" call to action.
|
||||
await page.goto("/");
|
||||
await page.getByRole("link", { name: "Log in" }).click();
|
||||
await signIn(page);
|
||||
|
||||
// Signed in on the app — NOT dumped on the Kratos /error "Page not found" page.
|
||||
await expect(page).not.toHaveURL(/\/error(\?|$)/);
|
||||
await expect(page.locator("h1"), 'must not land on the "Page not found" 404 view').not.toHaveText("Page not found");
|
||||
await expect(page.locator(".profile-mail")).toHaveText(ADMIN_EMAIL);
|
||||
});
|
||||
|
||||
test("entering on the wrong host (http://127.0.0.1:3000) is canonicalised to APP_URL and login still works", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
// The exact trigger from the bug report: a user types 127.0.0.1 instead of the advertised localhost.
|
||||
// The canonical-host redirect sends them to localhost before the flow starts, so the CSRF cookie
|
||||
// and the cross-origin Kratos POST share one host and login succeeds.
|
||||
await page.goto("http://127.0.0.1:3000/login");
|
||||
await expect(page).toHaveURL(/^http:\/\/localhost:3000\//); // 308'd onto the canonical host
|
||||
await signIn(page);
|
||||
|
||||
await expect(page).not.toHaveURL(/\/error(\?|$)/);
|
||||
await expect(page.locator(".profile-mail")).toHaveText(ADMIN_EMAIL);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { type Browser, type Page, expect, test } from "@playwright/test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// Full browser E2E: the real Playwright UI against the live stack via the same-origin
|
||||
// gateway (e2e-tests/compose.full.yml) — the browser-UI login the earlier full-stack suites deferred here.
|
||||
// Coverage is the test titles below, plus the standalone SSO test.
|
||||
//
|
||||
// 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, 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 expect(page.getByRole("link", { name: "Forgot password?" })).toBeVisible(); // a path to password reset
|
||||
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("/dashboard");
|
||||
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("/dashboard");
|
||||
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: /dashboard is gated, so it bounces back to the login page (no admin nav).
|
||||
await page.goto("/dashboard");
|
||||
await expect(page).toHaveURL(/\/login(\?|$)/);
|
||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("return_to: a deep link while logged out returns to that page after login", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
// A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos
|
||||
// flow server-side, so it's consumed, not shown in the settled URL).
|
||||
await page.goto("/admin/users");
|
||||
await expect(page).toHaveURL(/\/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();
|
||||
// Completion routes through /auth/complete (mints the JWT) and on to the requested page, not the dashboard.
|
||||
await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
|
||||
await expect(page.locator("h1")).toHaveText("Users");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// Mock OIDC provider for the SSO browser E2E — 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})`));
|
||||
@@ -0,0 +1,164 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// Full-stack OAuth2 login + consent E2E: another app logs in *through* plainpages. Hydra
|
||||
// starts an authorization flow and hands the browser to web's /oauth2/login; web resolves it via
|
||||
// the Kratos session and accepts, Hydra continues to web's /oauth2/consent, web shows the themed
|
||||
// consent screen, and Allow drives Hydra to issue the authorization code. We drive the flow over
|
||||
// HTTP (fetch, per-host cookie jars) because the browser hosts differ on the compose network; this
|
||||
// exercises web's server-side challenge handling. The browser-UI login is owned by the full-flow E2E (full-flow.spec.ts).
|
||||
const WEB = process.env.BASE_URL ?? "http://web:3000";
|
||||
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
||||
const HYDRA_PUBLIC = process.env.HYDRA_PUBLIC_URL ?? "http://hydra:4444";
|
||||
const HYDRA_ADMIN = process.env.HYDRA_ADMIN_URL ?? "http://hydra:4445";
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
|
||||
const ADMIN_PASSWORD = "admin";
|
||||
|
||||
function setCookieLine(res: Response, name: string): string | undefined {
|
||||
return res.headers.getSetCookie().find((c) => c.startsWith(`${name}=`));
|
||||
}
|
||||
function cookieValue(line: string): string {
|
||||
return line.split(";", 1)[0]!.slice(line.indexOf("=") + 1);
|
||||
}
|
||||
function relayCookies(res: Response): string {
|
||||
return res.headers.getSetCookie().map((c) => c.split(";", 1)[0]!).filter((kv) => kv.split("=")[1] !== "").join("; ");
|
||||
}
|
||||
|
||||
// Per-host cookie jar (the browser keeps Hydra's flow cookies separate from web's CSRF cookie).
|
||||
type Jar = Map<string, string>;
|
||||
function absorb(jar: Jar, res: Response): void {
|
||||
for (const line of res.headers.getSetCookie()) {
|
||||
const kv = line.split(";", 1)[0]!;
|
||||
const name = kv.slice(0, kv.indexOf("="));
|
||||
const value = kv.slice(kv.indexOf("=") + 1);
|
||||
if (value === "") jar.delete(name);
|
||||
else jar.set(name, value);
|
||||
}
|
||||
}
|
||||
const jarCookie = (jar: Jar): string => [...jar].map(([k, v]) => `${k}=${v}`).join("; ");
|
||||
// Hydra's resume URLs carry its issuer host (127.0.0.1:4444), unreachable from the runner —
|
||||
// rebase onto the compose-network host so we can follow them.
|
||||
function onHydra(url: string): string {
|
||||
const u = new URL(url);
|
||||
const host = new URL(HYDRA_PUBLIC);
|
||||
u.protocol = host.protocol;
|
||||
u.host = host.host;
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
// Register a confidential OAuth2 client (admin API) so we can start an authorization flow.
|
||||
async function createClient(): Promise<string> {
|
||||
const res = await fetch(`${HYDRA_ADMIN}/admin/clients`, {
|
||||
body: JSON.stringify({
|
||||
client_name: "e2e-login",
|
||||
grant_types: ["authorization_code"],
|
||||
redirect_uris: ["http://127.0.0.1:3000/callback"],
|
||||
response_types: ["code"],
|
||||
scope: "openid offline",
|
||||
token_endpoint_auth_method: "client_secret_post",
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
const body = await res.json().catch(() => null);
|
||||
expect(res.status, `create client: ${JSON.stringify(body)}`).toBe(201);
|
||||
return body.client_id;
|
||||
}
|
||||
|
||||
// Hit Hydra's authorization endpoint; it redirects to web's login URL carrying a login_challenge.
|
||||
// `jar` (when given) absorbs Hydra's flow cookies, needed to follow the login/consent verifiers.
|
||||
async function startAuthFlow(clientId: string, jar?: Jar): Promise<string> {
|
||||
const auth = new URL(`${HYDRA_PUBLIC}/oauth2/auth`);
|
||||
auth.search = new URLSearchParams({ client_id: clientId, redirect_uri: "http://127.0.0.1:3000/callback", response_type: "code", scope: "openid", state: "0123456789abcdef0123456789abcdef" }).toString();
|
||||
const res = await fetch(auth, { redirect: "manual" });
|
||||
if (jar) absorb(jar, res);
|
||||
expect([302, 303], `auth flow start: ${res.status}`).toContain(res.status);
|
||||
const location = res.headers.get("location") ?? "";
|
||||
expect(location, "Hydra redirects to our login URL").toContain("/oauth2/login");
|
||||
const challenge = new URL(location).searchParams.get("login_challenge");
|
||||
expect(challenge, "carries a login_challenge").toBeTruthy();
|
||||
return challenge!;
|
||||
}
|
||||
|
||||
// Authenticate the seeded admin via Kratos' browser login flow; return its session cookie value.
|
||||
async function kratosLogin(): Promise<string> {
|
||||
const init = await fetch(`${KRATOS}/self-service/login/browser`, { headers: { accept: "application/json" } });
|
||||
const flow = await init.json();
|
||||
const csrf = flow.ui.nodes.find((n: { attributes?: { name?: string } }) => n.attributes?.name === "csrf_token");
|
||||
const submit = await fetch(flow.ui.action, {
|
||||
body: JSON.stringify({ csrf_token: csrf?.attributes?.value ?? "", identifier: ADMIN_EMAIL, method: "password", password: ADMIN_PASSWORD }),
|
||||
headers: { accept: "application/json", "content-type": "application/json", cookie: relayCookies(init) },
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(submit.status, `login submit: ${await submit.text()}`).toBe(200);
|
||||
return cookieValue(setCookieLine(submit, "plainpages_session")!);
|
||||
}
|
||||
|
||||
test("Hydra login challenge: an unauthenticated user bounces to /login, an authenticated one is accepted", async () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const challenge = await startAuthFlow(await createClient());
|
||||
const loginUrl = `${WEB}/oauth2/login?login_challenge=${challenge}`;
|
||||
|
||||
// 1. No Kratos session → web bounces to the themed login, carrying a return_to back to the challenge.
|
||||
const anon = await fetch(loginUrl, { redirect: "manual" });
|
||||
expect(anon.status).toBe(303);
|
||||
const bounce = anon.headers.get("location") ?? "";
|
||||
expect(bounce).toMatch(/^\/login\?return_to=/);
|
||||
expect(decodeURIComponent(bounce.split("return_to=")[1]!)).toMatch(/\/oauth2\/login\?login_challenge=/);
|
||||
|
||||
// 2. With a live Kratos session → web accepts the challenge; Hydra hands back a resume URL.
|
||||
const session = await kratosLogin();
|
||||
const accepted = await fetch(loginUrl, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
|
||||
expect(accepted.status).toBe(303);
|
||||
const resume = accepted.headers.get("location") ?? "";
|
||||
expect(resume, "accepted → back to Hydra's /oauth2/auth to continue").toContain("/oauth2/auth");
|
||||
expect(resume, "carries Hydra's login_verifier").toContain("login_verifier");
|
||||
});
|
||||
|
||||
test("Hydra consent challenge: web shows the third-party consent screen; Allow → authorization code", async () => {
|
||||
test.setTimeout(60_000);
|
||||
const hydra: Jar = new Map(); // Hydra's flow cookies, needed to follow the verifiers
|
||||
const web: Jar = new Map(); // web's CSRF cookie
|
||||
|
||||
// Log in and accept the login challenge → Hydra resume URL (as in the login test).
|
||||
const challenge = await startAuthFlow(await createClient(), hydra);
|
||||
const session = await kratosLogin();
|
||||
const accepted = await fetch(`${WEB}/oauth2/login?login_challenge=${challenge}`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
|
||||
expect(accepted.status).toBe(303);
|
||||
|
||||
// Follow the login_verifier through Hydra → web's /oauth2/consent?consent_challenge=…
|
||||
const toConsent = await fetch(onHydra(accepted.headers.get("location") ?? ""), { headers: { cookie: jarCookie(hydra) }, redirect: "manual" });
|
||||
absorb(hydra, toConsent);
|
||||
const consentLoc = toConsent.headers.get("location") ?? "";
|
||||
expect(consentLoc, `→ web consent (${toConsent.status})`).toContain("/oauth2/consent");
|
||||
const consentChallenge = new URL(consentLoc).searchParams.get("consent_challenge")!;
|
||||
|
||||
// web shows the themed consent screen for this third-party client, listing the requested scope.
|
||||
const screen = await fetch(`${WEB}/oauth2/consent?consent_challenge=${consentChallenge}`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
|
||||
expect(screen.status).toBe(200);
|
||||
absorb(web, screen);
|
||||
const html = await screen.text();
|
||||
expect(html).toContain("Authorize e2e-login");
|
||||
expect(html).toContain("openid");
|
||||
expect(html, "names the signed-in account so consent is informed").toContain(`Signed in as <strong>${ADMIN_EMAIL}`);
|
||||
const csrf = html.match(/name="_csrf" value="([^"]+)"/)?.[1];
|
||||
expect(csrf, "consent form carries a CSRF token").toBeTruthy();
|
||||
|
||||
// Allow → web accepts the consent → Hydra resume URL with a consent_verifier.
|
||||
const allow = await fetch(`${WEB}/oauth2/consent`, {
|
||||
body: new URLSearchParams({ _csrf: csrf!, consent_challenge: consentChallenge, decision: "allow" }).toString(),
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${jarCookie(web)}; plainpages_session=${session}` },
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(allow.status, `allow consent: ${await allow.clone().text()}`).toBe(303);
|
||||
const consentResume = allow.headers.get("location") ?? "";
|
||||
expect(consentResume, "carries Hydra's consent_verifier").toContain("consent_verifier");
|
||||
|
||||
// Follow the consent_verifier through Hydra → the client callback with an authorization code.
|
||||
const toCallback = await fetch(onHydra(consentResume), { headers: { cookie: jarCookie(hydra) }, redirect: "manual" });
|
||||
const callback = toCallback.headers.get("location") ?? "";
|
||||
expect(callback, `→ client callback (${toCallback.status})`).toContain("/callback");
|
||||
expect(new URL(callback).searchParams.get("code"), "an authorization code is issued").toBeTruthy();
|
||||
});
|
||||
Generated
+78
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "plainpages-e2e",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "plainpages-e2e",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.49.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz",
|
||||
"integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.49.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
|
||||
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.49.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
|
||||
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "plainpages-e2e",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.49.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
// Visual + functional checks against the live app (the `web` compose service, BASE_URL). Run via
|
||||
// e2e-tests/compose.visual.yml. Parallel per the project's E2E principle; deterministic colorScheme/viewport
|
||||
// so the rendered design is stable across runs.
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
outputDir: "artifacts/test-output",
|
||||
fullyParallel: true,
|
||||
forbidOnly: true,
|
||||
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL ?? "http://localhost:3000",
|
||||
colorScheme: "light",
|
||||
screenshot: "only-on-failure",
|
||||
viewport: { width: 1280, height: 800 },
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
// Same-origin gateway for the browser E2E. 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}`));
|
||||
@@ -0,0 +1,145 @@
|
||||
import { createPrivateKey, sign } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const SHOTS = "artifacts/screenshots";
|
||||
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
|
||||
const SESSION_COOKIE = "plainpages_jwt"; // src/login.ts — web verifies it against the committed dev JWKS
|
||||
|
||||
const shot = (page: Page, name: string): Promise<Buffer> =>
|
||||
page.screenshot({ fullPage: true, path: `${SHOTS}/${name}.png` });
|
||||
|
||||
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
|
||||
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
|
||||
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
|
||||
function devSession(roles: string[] = []): string {
|
||||
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
|
||||
const key = createPrivateKey({ format: "jwk", key: jwk });
|
||||
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, roles, sub: "visual-demo" })}`;
|
||||
return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
|
||||
}
|
||||
|
||||
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
|
||||
|
||||
// The dashboard is gated: a page navigation needs a session. Plant one per test — a plain
|
||||
// member (no roles) so the gated scheduling/admin nav stays filtered out.
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
||||
});
|
||||
|
||||
test("captures the live pages for review", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
// the default /dashboard is the instructional starter, not a mock-data list.
|
||||
await expect(page.getByRole("heading", { name: "Starter dashboard" })).toBeVisible();
|
||||
await shot(page, "live-01-dashboard");
|
||||
|
||||
await page.goto("/dashboard");
|
||||
await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio
|
||||
await shot(page, "live-03-dark");
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/dashboard");
|
||||
await shot(page, "live-04-mobile");
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
});
|
||||
|
||||
test("every icon <use> resolves to a defined <symbol> (no broken graphics)", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
const missing = await page.evaluate(() => {
|
||||
const ids = new Set([...document.querySelectorAll("symbol[id]")].map((s) => s.id));
|
||||
return [...document.querySelectorAll("use")]
|
||||
.map((u) => (u.getAttribute("href") ?? "").replace(/^#/, ""))
|
||||
.filter((id) => id && !ids.has(id));
|
||||
});
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
// (The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component
|
||||
// (list-query/data-table/filter-bar) and exercised live with real data by the full-flow E2E's admin
|
||||
// Users list. The mock-data dashboard that used to host it in this Ory-free suite is gone.)
|
||||
|
||||
test("theme switch flips the palette with no JavaScript", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
const light = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
|
||||
await page.locator("#theme-dark").check({ force: true });
|
||||
const dark = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
|
||||
expect(dark).not.toBe(light);
|
||||
});
|
||||
|
||||
test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.locator(".hamburger")).toBeVisible();
|
||||
|
||||
const offCanvas = await page.locator(".sidebar").evaluate((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.right <= 1 || r.left >= window.innerWidth;
|
||||
});
|
||||
expect(offCanvas).toBe(true);
|
||||
});
|
||||
|
||||
test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a tokenless POST is refused", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
// The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit).
|
||||
const cookie = (await page.context().cookies()).find((c) => c.name === "plainpages_csrf");
|
||||
expect(cookie?.value, "GET /dashboard issues a plainpages_csrf cookie").toBeTruthy();
|
||||
const field = await page.locator('form[action="/logout"] input[name="_csrf"]').getAttribute("value");
|
||||
expect(field).toBe(cookie!.value);
|
||||
|
||||
// A POST carrying the cookie but no form token is rejected before any Kratos call.
|
||||
const res = await page.request.post("/logout", { form: {}, maxRedirects: 0 });
|
||||
expect(res.status()).toBe(403);
|
||||
});
|
||||
|
||||
test("the public landing at / is ungated and links to sign in + register", async ({ page, context }) => {
|
||||
await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
|
||||
await page.goto("/");
|
||||
await expect(page.locator(".landing")).toBeVisible();
|
||||
// the same app shell every page renders — the menu shows even signed out (role-filtered).
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
|
||||
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
|
||||
await shot(page, "live-05-public-landing");
|
||||
});
|
||||
|
||||
test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => {
|
||||
const res = await page.goto("/no-such-page");
|
||||
expect(res?.status()).toBe(404);
|
||||
await expect(page.getByRole("heading", { name: "Page not found" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Back home" })).toBeVisible();
|
||||
});
|
||||
|
||||
// The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is
|
||||
// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated,
|
||||
// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full
|
||||
// E2E (full-flow.spec). Side-effect-free.
|
||||
test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => {
|
||||
// `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these
|
||||
// probes are genuinely anonymous.
|
||||
// The public overview is reachable with no session (200), not bounced to sign in.
|
||||
const pub = await request.get("/scheduling", { maxRedirects: 0 });
|
||||
expect(pub.status()).toBe(200);
|
||||
const body = await pub.text();
|
||||
expect(body).toContain("Scheduling");
|
||||
// Anonymous in the native shell: the gated Dashboard link is hidden (it would only dead-end at
|
||||
// /login), and the shell's Sign-in link carries the current page as return_to.
|
||||
expect(body).not.toContain('href="/dashboard"');
|
||||
expect(body).toContain('href="/login?return_to=%2Fscheduling"');
|
||||
|
||||
// The gated shifts list still bounces (don't follow — this Ory-free suite has no /login handler);
|
||||
// assert the gate's 303 with the requested page preserved as return_to.
|
||||
const res = await request.get("/scheduling/shifts", { maxRedirects: 0 });
|
||||
expect(res.status()).toBe(303);
|
||||
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
|
||||
|
||||
// The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav,
|
||||
// but the gated Shifts leaf is filtered out.
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
||||
await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown
|
||||
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
|
||||
});
|
||||
Reference in New Issue
Block a user