§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:
14
README.md
14
README.md
@@ -219,7 +219,7 @@ otherwise drags up its `depends_on` services.
|
|||||||
### End-to-end (Playwright)
|
### End-to-end (Playwright)
|
||||||
|
|
||||||
E2E runs in the official Playwright image (browsers preinstalled) against the live `web`
|
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.
|
**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
|
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
|
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/`).
|
`--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
|
Screenshots + an HTML report land in `e2e/artifacts/` (git-ignored). Every user-facing flow
|
||||||
|
|||||||
101
compose.e2e-full.yml
Normal file
101
compose.e2e-full.yml
Normal file
@@ -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
|
||||||
114
e2e/full-flow.spec.ts
Normal file
114
e2e/full-flow.spec.ts
Normal 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
76
e2e/mock-oidc.mjs
Normal 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
24
e2e/proxy.mjs
Normal 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}`));
|
||||||
40
ory/kratos/e2e-proxy.yml
Normal file
40
ory/kratos/e2e-proxy.yml
Normal file
@@ -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
|
||||||
@@ -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, /name="password"[^>]*type="password"/);
|
||||||
assert.match(html, /<button type="submit"[^>]*name="method" value="password">Sign in<\/button>/);
|
assert.match(html, /<button type="submit"[^>]*name="method" value="password">Sign in<\/button>/);
|
||||||
assert.match(html, /<a href="\/registration">Create one<\/a>/); // alt link to register
|
assert.match(html, /<a href="\/registration">Create one<\/a>/); // alt link to register
|
||||||
// Configured OIDC provider → an SSO submit button in the same form (posts provider=google).
|
// Configured OIDC provider → an SSO submit button in the same form (posts provider=google);
|
||||||
|
// `formnovalidate` so it bypasses the required email/password fields (SSO needs neither).
|
||||||
assert.match(html, /<div class="sso"/);
|
assert.match(html, /<div class="sso"/);
|
||||||
assert.match(html, /<button type="submit" class="sso-btn" name="provider" value="google">.*Sign in with Google<\/span><\/button>/s);
|
assert.match(html, /<button type="submit" class="sso-btn" name="provider" value="google" formnovalidate>.*Sign in with Google<\/span><\/button>/s);
|
||||||
// The flow-level error renders as an alert.
|
// The flow-level error renders as an alert.
|
||||||
assert.match(html, /class="alert alert-neg"/);
|
assert.match(html, /class="alert alert-neg"/);
|
||||||
assert.match(html, /The provided credentials are invalid\./);
|
assert.match(html, /The provided credentials are invalid\./);
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ test("auth-card renders head, SSO providers (text logo + icon link), body slot a
|
|||||||
assert.match(html, /<div class="sso" aria-label="Single sign-on options"><ul class="sso-list">/);
|
assert.match(html, /<div class="sso" aria-label="Single sign-on options"><ul class="sso-list">/);
|
||||||
assert.match(html, /<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">G<\/span><span class="sso-label">Continue with Google<\/span><\/button><\/li>/);
|
assert.match(html, /<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">G<\/span><span class="sso-label">Continue with Google<\/span><\/button><\/li>/);
|
||||||
assert.match(html, /<li><a class="sso-btn" href="\/sso\/saml"><span class="sso-logo" aria-hidden="true"><svg class="ico ico-sm"><use href="#i-shield"\s*\/?><\/svg><\/span><span class="sso-label">Continue with SAML SSO<\/span><\/a><\/li>/);
|
assert.match(html, /<li><a class="sso-btn" href="\/sso\/saml"><span class="sso-logo" aria-hidden="true"><svg class="ico ico-sm"><use href="#i-shield"\s*\/?><\/svg><\/span><span class="sso-label">Continue with SAML SSO<\/span><\/a><\/li>/);
|
||||||
// A provider with name/value submits to the form (Kratos OIDC) — type="submit", not a decorative button.
|
// A provider with name/value submits to the form (Kratos OIDC) — type="submit", not a decorative
|
||||||
assert.match(html, /<li><button type="submit" class="sso-btn" name="provider" value="microsoft"><span class="sso-logo" aria-hidden="true">M<\/span><span class="sso-label">Sign in with Microsoft<\/span><\/button><\/li>/);
|
// button; `formnovalidate` so it bypasses the required email/password fields (SSO needs neither).
|
||||||
|
assert.match(html, /<li><button type="submit" class="sso-btn" name="provider" value="microsoft" formnovalidate><span class="sso-logo" aria-hidden="true">M<\/span><span class="sso-label">Sign in with Microsoft<\/span><\/button><\/li>/);
|
||||||
assert.match(html, /<\/ul><div class="auth-divider">or<\/div><\/div>/);
|
assert.match(html, /<\/ul><div class="auth-divider">or<\/div><\/div>/);
|
||||||
|
|
||||||
// Body slot lands inside .auth-form; alt footer renders text + link.
|
// Body slot lands inside .auth-form; alt footer renders text + link.
|
||||||
|
|||||||
6
todo.md
6
todo.md
@@ -118,8 +118,8 @@ everything via Docker.
|
|||||||
|
|
||||||
## 8. Testing & CI
|
## 8. Testing & CI
|
||||||
- [x] node --test units across helpers / router / nav / auth (tests-first throughout). → Audited unit coverage across the four areas; built tests-first through §0–§7, it was already near-complete — **helpers** (`list-query`/`paginate`/`body`/`icons`/`config`/`context`/`flow-view`/`gen-jwks`/`hooks`/`shell-context`, `static` via `app.test.ts`), **router** (`router`/`view-resolver`/`plugin`/`discovery`), **nav** (`nav`/`nav-tree`/`chrome`/`menu-config`/dashboard merge), **auth** (`jwt`/`jwt-middleware`/`jwks`/`guards`/`login`/`csrf`/`cookie`/`kratos-*`/`keto-client`/`oauth-*`/`hydra-admin`) all carry direct `node --test` units. **One genuine gap closed:** `admin-nav.ts` — its pure nav helpers (`adminSection`/`adminNav`) and security-critical auth gates (`requireAdmin`/`guardedForm`, the shared gate+CSRF preamble for every admin write) were exercised only *indirectly* via the admin HTTP integration tests. Added `src/admin-nav.test.ts` (tests-first style, against the existing contract): `adminSection` (gated "Admin" header over the 4 screens, `current` marks+opens), `adminNav` (prepends Dashboard, role-filters the section — admin sees it, non-admin/anon get only Dashboard; asserts via `href` since composeNav strips `id` but keeps `current`), `requireAdmin` (anon→401→/login, non-admin→403, admin→user), `guardedForm` (valid double-submit→parsed body, missing/forged token→403, non-POST→undefined), `buildConfirmModel`. Only `server.ts` (entry-point composition root, exercised by every E2E boot) has no dedicated unit. 300 → 305 units; typecheck + tests green. Tests-only, no production code (no stability reviewer, per the §6/§7 test-cleanup precedent).
|
- [x] node --test units across helpers / router / nav / auth (tests-first throughout). → Audited unit coverage across the four areas; built tests-first through §0–§7, it was already near-complete — **helpers** (`list-query`/`paginate`/`body`/`icons`/`config`/`context`/`flow-view`/`gen-jwks`/`hooks`/`shell-context`, `static` via `app.test.ts`), **router** (`router`/`view-resolver`/`plugin`/`discovery`), **nav** (`nav`/`nav-tree`/`chrome`/`menu-config`/dashboard merge), **auth** (`jwt`/`jwt-middleware`/`jwks`/`guards`/`login`/`csrf`/`cookie`/`kratos-*`/`keto-client`/`oauth-*`/`hydra-admin`) all carry direct `node --test` units. **One genuine gap closed:** `admin-nav.ts` — its pure nav helpers (`adminSection`/`adminNav`) and security-critical auth gates (`requireAdmin`/`guardedForm`, the shared gate+CSRF preamble for every admin write) were exercised only *indirectly* via the admin HTTP integration tests. Added `src/admin-nav.test.ts` (tests-first style, against the existing contract): `adminSection` (gated "Admin" header over the 4 screens, `current` marks+opens), `adminNav` (prepends Dashboard, role-filters the section — admin sees it, non-admin/anon get only Dashboard; asserts via `href` since composeNav strips `id` but keeps `current`), `requireAdmin` (anon→401→/login, non-admin→403, admin→user), `guardedForm` (valid double-submit→parsed body, missing/forged token→403, non-POST→undefined), `buildConfirmModel`. Only `server.ts` (entry-point composition root, exercised by every E2E boot) has no dedicated unit. 300 → 305 units; typecheck + tests green. Tests-only, no production code (no stability reviewer, per the §6/§7 test-cleanup precedent).
|
||||||
- [ ] **Playwright full E2E**: login (password + mocked SSO), menu filtering by role, users/groups/permissions CRUD, a plugin page, logout.
|
- [x] **Playwright full E2E**: login (password + mocked SSO), menu filtering by role, users/groups/permissions CRUD, a plugin page, logout. → New browser-UI suite `e2e/full-flow.spec.ts` (`compose.e2e-full.yml`) — the real Playwright UI the earlier full-stack suites deferred here ("browser-UI login is owned by §8"). The themed login form posts straight to Kratos' action and cookies are host-scoped, so a tiny **stdlib reverse proxy** (`e2e/proxy.mjs`) fronts web + Kratos on **one origin** (the browser's only host), exactly like a prod reverse proxy; `ory/kratos/e2e-proxy.yml` points Kratos' base_url + every self-service URL at it, and Kratos runs `--dev` so cookies aren't marked Secure over http (Kratos marks them Secure for a non-loopback host like the gateway). Coverage (6 tests, all green): **password login** (themed form → Kratos → `/auth/complete` → dashboard); **mocked SSO** (a stdlib **mock OIDC provider** `e2e/mock-oidc.mjs` — RS256-signed id_token, nonce-bound single-use codes — wired via `SELFSERVICE_METHODS_OIDC_*` env + the committed claims jsonnet; click the provider button → auto-approve → identity created → signed in); **menu filtering by role** (the admin sees the gated Admin section + the plugin nav; anon/SSO-user don't); **users/groups/roles CRUD** (create → list → delete a user via the confirm step; create a group + role, each with a first member since a Keto set needs ≥1); the **permission-gated plugin page** (`/scheduling/shifts` renders the mock upstream's shifts in the native shell); **logout** (sign-out ends the session → back to /login, admin nav gone). **Found + fixed a real bug the E2E surfaced:** the SSO submit button sits in the same `<form>` as the `required` email/password fields, so clicking it tripped HTML5 validation ("Please fill out this field") and never submitted — added `formnovalidate` to the SSO buttons (`views/partials/auth-card.ejs`), tests-first (`auth-card.test.ts` + `app.test.ts`); password login still validates (separate button). Stability-reviewer run as a local PR: **APPROVE, no Critical/High** — every dev/insecure knob (`--dev`, `SECURE_COOKIES=false`, the OIDC provider + mock + proxy) is confined to the e2e overlay, base/prod compose unaffected; addressed its top follow-up (documented the file's parallel-safety invariant). typecheck + **305 units** green; full-flow **6/6 green on a clean stack**, then `down -v` torn down. README E2E section + suite count updated.
|
||||||
- [ ] E2E harness: bring up the full compose stack, seed Keto roles + a test identity, **tear down after**.
|
- [x] E2E harness: bring up the full compose stack, seed Keto roles + a test identity, **tear down after**. → Delivered by the §8 full-flow harness (`compose.e2e-full.yml`): one `run` brings up the whole stack (Postgres + Kratos + Keto + the one-command **bootstrap** + web + the same-origin gateway + the reference plugin's mock upstream + the mock OIDC provider), the bootstrap **seeds the demo admin identity in Kratos + its Keto roles** (`admin` + the plugin's declared `scheduling:read`/`write` tokens), the browser suite runs against that seeded identity, and `docker compose … down -v` **tears everything down** (run live, 6/6 green, torn down). The §4 auth-refresh + §6 oauth-login suites use the same full-stack-up / seed / tear-down pattern; this completes it for the browser-UI flows.
|
||||||
- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues.
|
- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues.
|
||||||
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
|
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
|
||||||
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.
|
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.
|
||||||
@@ -128,7 +128,7 @@ everything via Docker.
|
|||||||
- [ ] `compose.yml` prod: Ory + Postgres, secrets via env, no source mount.
|
- [ ] `compose.yml` prod: Ory + Postgres, secrets via env, no source mount.
|
||||||
- [ ] Security headers; secure/HttpOnly/SameSite cookies; CSRF; clock-skew tolerance.
|
- [ ] Security headers; secure/HttpOnly/SameSite cookies; CSRF; clock-skew tolerance.
|
||||||
- [ ] Optional revocation denylist for instant role/session revoke.
|
- [ ] Optional revocation denylist for instant role/session revoke.
|
||||||
- [ ] Structured logging / basic observability. use @larvit/log for OTLP compability - but add subtasks and stuff for supporting incoming trace id etc from a reverse-proxy etc.
|
- [ ] Structured logging / basic observability. use @larvit/log for OTLP compability dig down in how to use it properly.
|
||||||
- [ ] JWT signing-key rotation runbook.
|
- [ ] JWT signing-key rotation runbook.
|
||||||
- [ ] Refresh README `Layout` + drop `_(planned)_` markers as pieces land.
|
- [ ] Refresh README `Layout` + drop `_(planned)_` markers as pieces land.
|
||||||
- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues.
|
- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<div class="auth-head"><% if (back) { %><a class="auth-back" href="<%= back.href %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-arrow-left"/></svg><%= back.label %></a><% } %><h1><%= locals.title %></h1><% if (locals.sub) { %><p class="auth-sub"><%= locals.sub %></p><% } %></div>
|
<div class="auth-head"><% if (back) { %><a class="auth-back" href="<%= back.href %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-arrow-left"/></svg><%= back.label %></a><% } %><h1><%= locals.title %></h1><% if (locals.sub) { %><p class="auth-sub"><%= locals.sub %></p><% } %></div>
|
||||||
<% if (providers.length) { -%>
|
<% if (providers.length) { -%>
|
||||||
<div class="sso" aria-label="<%= sso.label || "Single sign-on options" %>">
|
<div class="sso" aria-label="<%= sso.label || "Single sign-on options" %>">
|
||||||
<ul class="sso-list"><% providers.forEach((p) => { %><li><% if (p.href) { %><a class="sso-btn" href="<%= p.href %>"><% } else { %><button type="<%= p.name ? "submit" : "button" %>" class="sso-btn"<% if (p.name) { %> name="<%= p.name %>" value="<%= p.value %>"<% } %>><% } %><span class="sso-logo" aria-hidden="true"><% if (p.icon) { %><svg class="ico ico-sm"><use href="#<%= p.icon %>"/></svg><% } else { %><%= p.logo %><% } %></span><span class="sso-label"><%= p.label %></span><% if (p.href) { %></a><% } else { %></button><% } %></li><% }) %></ul>
|
<ul class="sso-list"><% providers.forEach((p) => { %><li><% if (p.href) { %><a class="sso-btn" href="<%= p.href %>"><% } else { %><button type="<%= p.name ? "submit" : "button" %>" class="sso-btn"<% if (p.name) { %> name="<%= p.name %>" value="<%= p.value %>" formnovalidate<% } %>><% } %><span class="sso-logo" aria-hidden="true"><% if (p.icon) { %><svg class="ico ico-sm"><use href="#<%= p.icon %>"/></svg><% } else { %><%= p.logo %><% } %></span><span class="sso-label"><%= p.label %></span><% if (p.href) { %></a><% } else { %></button><% } %></li><% }) %></ul>
|
||||||
<div class="auth-divider"><%= sso.divider || "or" %></div>
|
<div class="auth-divider"><%= sso.divider || "or" %></div>
|
||||||
</div>
|
</div>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
|
|||||||
Reference in New Issue
Block a user