From 5630d863b4f8124895c139badaaf171184b0df2f Mon Sep 17 00:00:00 2001 From: crazyracer98 <8631139+crazyracer98@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:20:57 +0000 Subject: [PATCH 1/9] feat(bff): wire page views, drop the config.js path, fix BF4 nits, write the BFF docs (BF5a) - App calls usePageViews() once; no consent UI - Remove /config.js script and template; .env.example documents the dev BFF vars - Runner FROM derives from NODE_VERSION/DEBIAN_VERSION (digest kept); bff tests out of the image - compose.instance.yml: dev BFF env (PUBLIC_ORIGIN, OIDC_*, BFF_UPSTREAMS); 8081 port label - ADR 0007 (supersedes 0001, 0002), ADR 0008, FR-0001, docs/architecture.md; README, scripts docs, 0003/0006 notes --- .devcontainer/compose.instance.yml | 25 +++++- .devcontainer/devcontainer.json | 2 +- .dockerignore | 4 + .env.example | 32 +++++--- Dockerfile | 8 +- README.md | 82 ++++++++++++------- docs/TEMPLATE.md | 4 +- .../0001-oidc-auth-matching-the-backend.md | 4 +- docs/adrs/0002-same-origin-api-proxy.md | 5 +- .../0003-re-parent-onto-template-docker.md | 8 ++ docs/adrs/0006-manifest-driven-admin.md | 8 ++ docs/adrs/0007-adopt-craftainer-bff.md | 80 ++++++++++++++++++ docs/adrs/0008-spa-analytics-and-consent.md | 64 +++++++++++++++ docs/architecture.md | 79 ++++++++++++++++++ docs/frs/FR-0001-sign-in-through-the-bff.md | 42 ++++++++++ index.html | 4 - scripts/README.md | 5 +- scripts/runner-setup.d/README.md | 2 +- scripts/runner-setup.sh | 2 +- src/App.test.tsx | 10 +++ src/App.tsx | 5 ++ src/public/config.js.template | 9 -- 22 files changed, 415 insertions(+), 69 deletions(-) create mode 100644 docs/adrs/0007-adopt-craftainer-bff.md create mode 100644 docs/adrs/0008-spa-analytics-and-consent.md create mode 100644 docs/architecture.md create mode 100644 docs/frs/FR-0001-sign-in-through-the-bff.md delete mode 100644 src/public/config.js.template diff --git a/.devcontainer/compose.instance.yml b/.devcontainer/compose.instance.yml index 7d28312..96ff4cf 100644 --- a/.devcontainer/compose.instance.yml +++ b/.devcontainer/compose.instance.yml @@ -18,10 +18,29 @@ services: # to the backend". extra_hosts: - "host.docker.internal:host-gateway" - # The dev BFF's session store. Plaintext is the documented dev-only - # opt-out (the BFF refuses plaintext non-loopback URLs otherwise); see - # stack/valkey/README.md. + # The dev BFF (`pnpm dev:bff`, port 8787; Vite on 5173 proxies to it). All + # values are dev-only placeholders: the realm and the `bff` client secret + # are template-fastapi's devcontainer Keycloak (its + # .devcontainer/stack/keycloak/keycloak.env, reached through the host's + # forwarded port 8080 like the API on 8000). environment: + # The browser-facing origin of the dev app (the Vite server); it derives + # redirect_uri and the Origin check. + PUBLIC_ORIGIN: http://localhost:5173 + # The `iss` Keycloak puts in tokens (its KC_HOSTNAME)... + OIDC_ISSUER: http://localhost:8080/realms/template-fastapi + # ...while this container reaches Keycloak through the host gateway + # (its realm sets KC_HOSTNAME_BACKCHANNEL_DYNAMIC, so the discovery + # document's token and JWKS endpoints use the host the BFF asked). + OIDC_DISCOVERY_URL: http://host.docker.internal:8080/realms/template-fastapi/.well-known/openid-configuration + OIDC_CLIENT_SECRET: dev-only-bff-client-secret + # Plain-http IdP: development only (refused when NODE_ENV=production). + OIDC_ALLOW_INSECURE: "true" + BFF_PORT: "8787" + BFF_UPSTREAMS: '{"crud":{"url":"http://host.docker.internal:8000","audience":"api","methods":["GET","POST","PUT","PATCH","DELETE"]}}' + # The BFF's session store. Plaintext is the documented dev-only opt-out + # (the BFF refuses plaintext non-loopback URLs otherwise); see + # stack/valkey/README.md. SESSION_STORE_URL: redis://valkey:6379 SESSION_STORE_ALLOW_PLAINTEXT: "true" depends_on: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4daf307..33b6285 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,7 +20,7 @@ "onAutoForward": "openPreview" }, "8081": { - "label": "Production image (BFF)", + "label": "Production image (BFF; run with -p 8081:8080, the container listens on 8080)", "onAutoForward": "silent" }, "8787": { diff --git a/.dockerignore b/.dockerignore index 177001b..c06dac9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,6 +17,10 @@ test-results README.md CLAUDE.md +# The BFF's tests never run in the image; the bff-deploy stage copies bff/src +# into the runner. +bff/src/**/*.test.ts + # Node (app runtime): the builder stage runs its own `pnpm install` / # `pnpm build`; without these, `COPY . .` (after that install) would overwrite # the container's node_modules with the host's, which may not match its diff --git a/.env.example b/.env.example index 668de81..e03143a 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,23 @@ # Copy to .env and adjust for your setup; see README.md's "Connecting to -# the backend" and docs/adrs/0002-same-origin-api-proxy.md. +# the backend" and docs/adrs/0007-adopt-craftainer-bff.md. +# +# Only Vite reads this file (vite.config.ts). The dev BFF reads real +# environment variables instead: in the devcontainer they come from +# .devcontainer/compose.instance.yml. -# Where the Vite dev server proxies /api to. Defaults (in vite.config.ts) -# to http://host.docker.internal:8000, reaching the sibling template-fastapi -# repo's own devcontainer via its forwarded port 8000. Override if you run -# the backend directly on the host instead (http://localhost:8000) or on a -# different port. -# VITE_API_PROXY_TARGET=http://localhost:8000 +# Where the Vite dev server proxies /bff, /api and /.well-known to: the dev +# BFF (`pnpm dev:bff`). Defaults to http://localhost:8787. The BFF itself +# reaches the backend through BFF_UPSTREAMS. +# VITE_BFF_PROXY_TARGET=http://localhost:8787 -# OIDC settings (src/auth/config.ts) -- defaults match the backend's own -# devcontainer Keycloak realm/client, so these are only needed to point at -# a different realm. -# VITE_OIDC_AUTHORITY=http://localhost:8080/realms/template-fastapi -# VITE_OIDC_CLIENT_ID=api +# The dev BFF's environment, when you run `pnpm dev:bff` outside the +# devcontainer (placeholders; the secret is template-fastapi's dev-only one). +# Export these in your shell; Vite does not read them. +# PUBLIC_ORIGIN=http://localhost:5173 +# OIDC_ISSUER=http://localhost:8080/realms/template-fastapi +# OIDC_CLIENT_SECRET=dev-only-bff-client-secret +# OIDC_ALLOW_INSECURE=true +# SESSION_STORE_URL=redis://localhost:6379 +# SESSION_STORE_ALLOW_PLAINTEXT=true +# BFF_PORT=8787 +# BFF_UPSTREAMS={"crud":{"url":"http://localhost:8000","audience":"api","methods":["GET","POST","PUT","PATCH","DELETE"]}} diff --git a/Dockerfile b/Dockerfile index fd4c03b..7602dee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,8 +56,12 @@ RUN pnpm --filter ./bff deploy --prod /out # runner -- Node, unprivileged (`node`, uid 1000), digest-pinned. Runs the # BFF on 8080: it serves the built SPA (BFF_STATIC_DIR), the /bff/* auth # routes and the reverse proxy to the API upstreams. Renovate updates the -# tag and digest together; keep the tag equal to -# NODE_VERSION-DEBIAN_VERSION-slim (the digest is the multi-arch index's). +# tag and digest together. Keep the tag equal to +# NODE_VERSION-DEBIAN_VERSION-slim (the digest is the multi-arch index's): a +# digest wins over the tag when Docker pulls, so the tag cannot be derived from +# the ARGs here; when the ARGs change, change this line with them (the +# builder's node binary is what the tests ran against). No script checks this +# yet. ######################################## # renovate: datasource=docker depName=node FROM node:24.22.1-trixie-slim@sha256:89c5244bc22a9bc3287d89ebc7e49b9cb5bb84cd40e6175ac3421d5a9f68b95a AS runner diff --git a/README.md b/README.md index 032922b..798a0d4 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ The React frontend for [`template-fastapi`](https://github.com/craftainer/template-fastapi): Vite + React + TypeScript + MUI, authenticating against that backend's -own Keycloak realm and talking to it exclusively through a same-origin -`/api` proxy. Itself an instance of +own Keycloak realm through a Node backend-for-frontend (`bff/`, built on +[`@craftainer/bff`](https://github.com/craftainer/lib-bff-ts)) that keeps the +tokens server-side and proxies `/api//*`. Itself an instance of [`template-docker`](https://github.com/craftainer/template-docker) (the shared image layer, itself built on [`template-base`](https://github.com/craftainer/template-base)) — see @@ -19,10 +20,9 @@ See [`docs/adrs/`](docs/adrs/) for the reasoning behind this app's own decisions: - [`0001-oidc-auth-matching-the-backend.md`](docs/adrs/0001-oidc-auth-matching-the-backend.md) — - auth via `react-oidc-context` against the backend's existing realm. + superseded by 0007 (browser-side OIDC). - [`0002-same-origin-api-proxy.md`](docs/adrs/0002-same-origin-api-proxy.md) — - the `/api` proxy (dev and prod), cross-repo dev networking, and the - production image's runtime-configurable settings. + superseded by 0007 (the nginx `/api` proxy and runtime `config.js`). - [`0003-re-parent-onto-template-docker.md`](docs/adrs/0003-re-parent-onto-template-docker.md) — the image layer from `template-docker`, the `Dockerfile` layout, `image.mk`, and the UI dependency set. @@ -37,6 +37,13 @@ decisions: - [`0006-manifest-driven-admin.md`](docs/adrs/0006-manifest-driven-admin.md) — the admin built on `@craftainer/crud-sdk` and `@craftainer/crud-views`, from the backend's CRUD manifest, and its end-to-end test. +- [`0007-adopt-craftainer-bff.md`](docs/adrs/0007-adopt-craftainer-bff.md) — + the BFF: cookie sessions, no tokens in the browser, the Node runner image. +- [`0008-spa-analytics-and-consent.md`](docs/adrs/0008-spa-analytics-and-consent.md) — + page views and events through the BFF, implied consent, GPC/DNT. + +Requirements: [`FR-0001`](docs/frs/FR-0001-sign-in-through-the-bff.md); +the services and how they connect: [`docs/architecture.md`](docs/architecture.md). ## Getting started @@ -47,8 +54,10 @@ via `scripts/post-create.d/10-pnpm-install.sh`, the dependencies automatically. Then: ```sh -pnpm dev # Vite dev server, http://localhost:5173 +pnpm dev:bff # the dev BFF on :8787 (needs Keycloak, the backend and Valkey) +pnpm dev # Vite dev server, http://localhost:5173 (proxies to the BFF) pnpm typecheck # tsc -b +pnpm typecheck:bff # tsc -p bff pnpm lint # oxlint pnpm format # prettier --check . pnpm test # vitest run @@ -61,34 +70,51 @@ tests/e2e/run.sh # admin journey against a running template-fastapi ( ## Connecting to the backend -This app never talks to the backend directly from the browser — every -request goes through a same-origin `/api` proxy (`vite.config.ts` in -dev, `scripts/docker/nginx.conf.template` in the built image), so the backend -never needs CORS configured for this app. See -[`docs/adrs/0002-same-origin-api-proxy.md`](docs/adrs/0002-same-origin-api-proxy.md) -for the full reasoning; in short: +The browser never talks to the backend, or to Keycloak with a token: it +talks to the BFF on its own origin (`/bff/*` for sign-in and session, +`/api/crud/*` for the backend), so the backend needs no CORS and the page +holds no token. See +[`docs/adrs/0007-adopt-craftainer-bff.md`](docs/adrs/0007-adopt-craftainer-bff.md); +in short: - **Both repos' devcontainers open side by side** (the common case): no - configuration needed. `VITE_API_PROXY_TARGET` defaults to - `http://host.docker.internal:8000`, reaching `template-fastapi`'s own - devcontainer via its forwarded port 8000. -- **Backend running directly on the host** instead of in its own - devcontainer: copy `.env.example` to `.env` and set - `VITE_API_PROXY_TARGET=http://localhost:8000`. -- **Sign-in**: `VITE_OIDC_AUTHORITY`/`VITE_OIDC_CLIENT_ID` (also in - `.env.example`) default to the backend's own devcontainer Keycloak - realm/client — no configuration needed for the same common case above. - -The built production image takes the equivalent settings as real -container env vars instead (`API_PROXY_TARGET`, `OIDC_AUTHORITY`, -`OIDC_CLIENT_ID`) — see the ADR's "Runtime-configurable production -image" section. + configuration needed. `.devcontainer/compose.instance.yml` gives the dev + BFF (`pnpm dev:bff`, port 8787) its environment: the backend at + `http://host.docker.internal:8000` (`BFF_UPSTREAMS`), template-fastapi's + Keycloak realm at port 8080, its dev-only `bff` client secret, and the + Valkey service. `pnpm dev` then serves the SPA on 5173 and proxies + `/bff`, `/api` and `/.well-known` to the BFF. +- **Backend running directly on the host**: export the variables in + `.env.example` (`BFF_UPSTREAMS` with `http://localhost:8000`). +- **Another BFF address for Vite**: `VITE_BFF_PROXY_TARGET` in `.env`. + +### Runtime configuration + +The BFF reads real environment variables (there is no `config.js` and no +build-time OIDC setting). It refuses to start without: + +| Variable | Meaning | +| -------------------- | ------------------------------------------------------------------------------------------------------- | +| `PUBLIC_ORIGIN` | Browser-facing origin, e.g. `https://app.example.com`; derives the redirect URI and the Origin check. | +| `OIDC_ISSUER` | The expected `iss` of the IdP's tokens (the realm URL). | +| `OIDC_CLIENT_SECRET` | Secret of the confidential `bff` client. | +| `SESSION_STORE_URL` | Valkey/Redis URL; `rediss://` outside local dev (plaintext needs `SESSION_STORE_ALLOW_PLAINTEXT=true`). | + +Also commonly set: `BFF_UPSTREAMS` (JSON allowlist, `crud` by default +mounted at `/api/crud/*`), `OIDC_DISCOVERY_URL` (when the BFF reaches the IdP +at another host than `iss`), `ANALYTICS_*` and `SECURITY_TXT_*`. Every +variable is documented in the +[`@craftainer/bff` README](https://github.com/craftainer/lib-bff-ts#configuration). ## Production image and releases The runtime image is the top-level `Dockerfile`'s `runner` stage -(`docker build --target runner .`); `.devcontainer/Dockerfile` only builds -the devcontainer. `release.yml` drives the template-docker-owned +(`docker build --target runner .`): a digest-pinned Node image running +`bff/` as the unprivileged `node` user on port 8080, serving the built SPA. +Run it with the required variables above, publishing container port 8080 on +host port 8081 (`-p 8081:8080`, with `PUBLIC_ORIGIN=http://localhost:8081`), +because 8080 is Keycloak in the devcontainer. `/health/live` needs neither Valkey nor the +IdP. `.devcontainer/Dockerfile` only builds the devcontainer. `release.yml` drives the template-docker-owned `Makefile` (tuned by this repo's `image.mk`), which builds it natively for `amd64` and `arm64`, smoke-tests each image, and, when the `OCI_REGISTRY` repository variable is set (e.g. `ghcr.io`), copies the tested images to diff --git a/docs/TEMPLATE.md b/docs/TEMPLATE.md index 91e56e4..13b9529 100644 --- a/docs/TEMPLATE.md +++ b/docs/TEMPLATE.md @@ -436,8 +436,8 @@ removes the container and the private `image-smoke-*` tag it created Optional. It serves a runtime image that needs the group-0 user and writable directories; an instance whose `runner` stage doesn't call it -(e.g. `template-react`'s nginx runner, whose base image already has its own -non-root user) simply never runs it. Both files stay in the tree, since +(e.g. `template-react`'s Node runner, whose base image already has its own +non-root `node` user) simply never runs it. Both files stay in the tree, since they are template-owned, and nothing checks or requires them. `scripts/runner-setup.sh` creates the user (UID `APP_UID`, group 0), makes diff --git a/docs/adrs/0001-oidc-auth-matching-the-backend.md b/docs/adrs/0001-oidc-auth-matching-the-backend.md index 971974b..3072f22 100644 --- a/docs/adrs/0001-oidc-auth-matching-the-backend.md +++ b/docs/adrs/0001-oidc-auth-matching-the-backend.md @@ -2,7 +2,9 @@ ## Status -Accepted +Superseded by [0007](0007-adopt-craftainer-bff.md): sign-in now happens in +the BFF, and the SPA holds no tokens. The backend-realm reasoning below +still describes why the realm is `template-fastapi`'s. ## Context diff --git a/docs/adrs/0002-same-origin-api-proxy.md b/docs/adrs/0002-same-origin-api-proxy.md index f263d31..cd87f9f 100644 --- a/docs/adrs/0002-same-origin-api-proxy.md +++ b/docs/adrs/0002-same-origin-api-proxy.md @@ -2,7 +2,10 @@ ## Status -Accepted +Superseded by [0007](0007-adopt-craftainer-bff.md): the proxy is now the +BFF's allowlisted `/api//*` proxy (dev and production), and there +is no nginx or runtime `config.js` any more. The same-origin-instead-of-CORS +reasoning still holds. ## Context diff --git a/docs/adrs/0003-re-parent-onto-template-docker.md b/docs/adrs/0003-re-parent-onto-template-docker.md index 0c33689..f8aebe4 100644 --- a/docs/adrs/0003-re-parent-onto-template-docker.md +++ b/docs/adrs/0003-re-parent-onto-template-docker.md @@ -6,6 +6,14 @@ Accepted Note (2026-10-26, LIC1): the project licence identifier is now `GPL-3.0-only`, not `GPL-3.0-or-later`; the `OCI_LICENSE` default and the `licenses` label follow it. The `GPL-3.0-or-later` mentions below record what was decided at the time. +Note (2026-11, BF4/BF5a, ADR 0007): the runner is no longer nginx. It is +`node:${NODE_VERSION}-${DEBIAN_VERSION}-slim` (digest-pinned, user `node`) +running `bff/` on 8080, so the nginx base, `scripts/docker/` (nginx template and +envsubst entrypoint) and `-e API_PROXY_TARGET` in `IMAGE_SMOKE_RUN_ARGS` are +gone; the smoke test hits `/health/live`. The runner still does not use +`runner-setup.sh`. The `IMAGE_SMOKE_*` table below records what was decided at +the time. + ## Context template-react was an instance of `template-base` that carried its own diff --git a/docs/adrs/0006-manifest-driven-admin.md b/docs/adrs/0006-manifest-driven-admin.md index 499b761..bd4732a 100644 --- a/docs/adrs/0006-manifest-driven-admin.md +++ b/docs/adrs/0006-manifest-driven-admin.md @@ -4,6 +4,14 @@ Accepted +Note (2026-11, ADR 0007): the browser no longer holds an OIDC session. The +token provider and `signinSilent()` in "Client wiring" are replaced by cookie +sessions with the BFF (`credentials: "include"`, `X-CSRF: 1`, `cacheScope` from +`/bff/user`'s `sub`), the `/api` prefix is the BFF's `/api/crud` upstream, and +production no longer uses nginx (the "adds no such header" remark is moot; the +BFF sets its own headers). The query-string navigation here is what ADR 0008's +page views read. + ## Context `HeroesPage` was written for one resource: it hardcoded the Hero columns and diff --git a/docs/adrs/0007-adopt-craftainer-bff.md b/docs/adrs/0007-adopt-craftainer-bff.md new file mode 100644 index 0000000..b9481da --- /dev/null +++ b/docs/adrs/0007-adopt-craftainer-bff.md @@ -0,0 +1,80 @@ +# 0007. Adopt `@craftainer/bff`: cookie sessions behind a Node server, no tokens in the browser + +## Status + +Accepted. Supersedes [0001](0001-oidc-auth-matching-the-backend.md) and +[0002](0002-same-origin-api-proxy.md). + +## Context + +Until now the browser ran the OIDC Authorization Code + PKCE flow itself +(`react-oidc-context`), held the access token in JavaScript and sent it as a +bearer token through an nginx `/api` proxy whose settings were rendered at +container start by `envsubst` into `config.js` (ADRs 0001 and 0002). Three +things made that a poor base for a template: + +- Tokens reachable from JavaScript are readable by any script that runs in + the page (OWASP A02/A07; the current OAuth browser-based-apps guidance and + RFC 10017 on the BFF pattern recommend keeping them out of the browser). +- The nginx image could not hold a session, check `Origin`, serve + `/.well-known/*`, or receive analytics events, and the `envsubst` config + path was a second configuration mechanism next to Vite's. +- The same server-side pieces are wanted by every UI template, so they belong + in a library (`@craftainer/bff`, repository `craftainer/lib-bff-ts`). + +## Decision + +We will run a Node server in front of the SPA, built from the library, and +the SPA holds no tokens: + +- **Library plus a thin `bff/`.** `bff/` is a private pnpm workspace member + that calls `loadConfig(process.env)` and `createBff`, adds this app's own + routes and listens (`bff/README.md`). Behaviour (login, sessions, CSRF, + headers, proxy, well-known, analytics, static files) stays in the library; a + defect there is fixed in the library and released, not patched in `bff/`. + The version is pinned exactly in `bff/package.json`. +- **Login.** The BFF is the confidential OIDC client (`bff`) of + template-fastapi's Keycloak realm. The browser holds one `__Host-Http-` + session cookie (`Secure; HttpOnly; SameSite=Strict`); sessions, refresh + tokens and login transactions live in Valkey. `PUBLIC_ORIGIN`, never the + `Host` header, derives `redirect_uri` and the Origin check. +- **SPA contract.** `GET /bff/user` (401 means "navigate to `/bff/login`"), + `GET /bff/login?returnTo=`, `POST /bff/logout`, `GET /bff/config`. Every + `/api/*` request and every non-GET `/bff/*` request carries `X-CSRF: 1`. + Roles from `/bff/user` are for the UI only; the backend still authorises. +- **API access.** `BFF_UPSTREAMS` is an allowlist; the default instance has + one upstream, `crud`, mounted at `/api/crud/*` (prefix stripped). The BFF adds + `Authorization: Bearer`, refreshing the token when needed. The browser never + sees it, and inbound `Authorization`/`Cookie` headers are never forwarded. + This keeps ADR 0002's reason: same origin, so no CORS on the backend. +- **Dev.** Vite (5173) proxies `/bff`, `/api` and `/.well-known` to the dev BFF + (8787, `pnpm dev:bff`), whose environment comes from + `.devcontainer/compose.instance.yml`. The BFF reaches the sibling backend + (port 8000) and Keycloak (port 8080) through `host.docker.internal`, as + ADR 0002 set up. Valkey is a stack service (`.devcontainer/stack/valkey/`). +- **Image.** `Dockerfile` builds the SPA and `pnpm --filter ./bff deploy +--prod`, then runs `node src/main.ts` (type stripping, no build step) as + the unprivileged `node` user on a digest-pinned `node:*-slim` image, port 8080. npm, npx and corepack are removed from the runner. The smoke test is + `/health/live`, which needs neither Valkey nor the IdP. +- **Configuration.** Required at runtime: `PUBLIC_ORIGIN`, `OIDC_ISSUER`, + `OIDC_CLIENT_SECRET`, `SESSION_STORE_URL` (`rediss://` outside the dev + stack). `loadConfig` lists every problem at startup. There is no + `config.js`, no `VITE_OIDC_*` and no `envsubst`. + +## Consequences + +- No access or refresh token is ever reachable from script, and sessions can + be ended server-side (logout, expiry, back-channel logout). +- A deployment now needs Valkey (or a compatible store) and a client secret, + and its IdP must register `/bff/callback` and the back-channel + logout URL. Losing Valkey signs everyone out. +- The BFF is one more process to patch: library releases arrive through + Renovate as exact-version bumps. +- Rate limits are in memory, per replica, until a Valkey-backed limiter is + shown to work. The visitor-token for analytics has the same replica limit. +- Static serving and the security headers move from nginx into the library, + so the CSP is the library's; an app that needs a different one changes it + through the library. +- Moving between hosts or origins changes `PUBLIC_ORIGIN` and the realm's + redirect URIs together. Image runs on a developer machine use host port 8081 + (`8081:8080`), because 8080 is Keycloak. diff --git a/docs/adrs/0008-spa-analytics-and-consent.md b/docs/adrs/0008-spa-analytics-and-consent.md new file mode 100644 index 0000000..f302209 --- /dev/null +++ b/docs/adrs/0008-spa-analytics-and-consent.md @@ -0,0 +1,64 @@ +# 0008. SPA analytics: page views and events through the BFF, consent decided server-side + +## Status + +Accepted + +## Context + +The app wants privacy-preserving usage numbers (which admin views are used), +without an analytics script from a third party, cookies set from JavaScript, +or identifiers in the browser. The BFF already sees the request (IP, user +agent, GPC and DNT headers) and owns the analytics intake (`POST +/bff/analytics`, adapters `none`, `log`, `umami`, `otel`; `@craftainer/bff` +ADR 0004), so the open questions were what the browser sends, who decides +whether an event counts, and how page views work without a router (ADR 0006). + +## Decision + +- **The browser sends events, nothing else.** `src/analytics/track.ts`: + `track(name, props?, url?)` POSTs to `/bff/analytics` with `fetch`, using + `keepalive` and the `X-CSRF: 1` header (never `sendBeacon`, which cannot + carry the header). The name matches the dotted lower-case pattern, values + are scalars of at most 200 characters, and `url` is a bare path. The browser + sends no id, title, referrer or query string. +- **Fail closed.** `track()` never throws into the UI, never retries, and stays + quiet for five minutes after a transport failure or throttling. It also skips + the request when `navigator.globalPrivacyControl` is true or Do Not Track is + `1`; the BFF drops such requests as well (`ANALYTICS_RESPECT_GPC`), so + neither side trusts the other. +- **Consent is decided by the BFF.** The default mode is `implied`: events + count unless the visitor declined, the legitimate-interest posture that + fits an audience-measurement tool configured to the CNIL exemption + conditions (first-party, no cross-site tracking, no raw IP, short-lived + pseudonymous token, a purpose limited to audience measurement). The + choice lives in the BFF's `__Host-Http-consent` cookie: `1` granted, `0` + declined (`0` stops recording in every mode, including `implied`). The SPA + reads and sets it through `GET`/`POST /bff/consent` (`useConsent`); `null` + means unknown and is treated as "not granted" by the SPA's own sends. A + deployment that needs prior consent sets `ANALYTICS_CONSENT=opt-in` with no + SPA change. +- **No ids in page paths.** There is no router, so a "page" is the pathname + plus the admin's resource and view from the `UrlAdapter` query state (ADR + 0006), e.g. `/heroes/trash`. Record ids, filters and paging never leave + the browser; a record page reports `/heroes/record`. `usePageViews()` is + called once in `App` and sends one `page.view` per distinct reported path, + so a filter change sends nothing. +- **Properties are allowlisted server-side** (`ANALYTICS_PROP_KEYS`, default + empty), so adding a property is a deliberate change on both sides. +- **No consent banner yet.** The default needs none. Whether and how to ask + is a design decision for the maintainers; until then `useConsent` exists + with no UI. + +## Consequences + +- No cookie, storage entry or id is created by the SPA's analytics; the only + cookie is the BFF's consent cookie, and only when a choice is stored. +- Page-view counts under `implied` consent include visitors who never saw a + choice; a deployment in a stricter setting must switch to `opt-in` and add + the UI first, or no events will be recorded. +- `viewPath` sends the pathname as is, so a path containing an id would be + reported; the admin's paths do not. A router later must map routes to + templates before reporting. +- Dev and CI default to `ANALYTICS_BACKEND=none`; pointing it at Umami + needs `ANALYTICS_ENDPOINT` and `ANALYTICS_SITE_ID`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..7aef352 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,79 @@ +# Architecture + +The infrastructure view: which processes and services exist, how they +connect, and how the devcontainer differs from the production image. Why the +BFF exists is in [ADR 0007](adrs/0007-adopt-craftainer-bff.md); analytics in +[ADR 0008](adrs/0008-spa-analytics-and-consent.md). + +## Devcontainer + +Two devcontainers run side by side on one machine: this repo's and +`template-fastapi`'s. They are separate Compose projects, so they meet +through ports the host forwards (`host.docker.internal`), not a shared +network. + +```mermaid +flowchart LR + browser["Browser
http://localhost:5173"] + subgraph react["template-react devcontainer (myapp)"] + vite["Vite dev server :5173
(pnpm dev)"] + bff["Dev BFF :8787
(pnpm dev:bff, @craftainer/bff)"] + end + valkey[("valkey :6379
sessions")] + subgraph fastapi["template-fastapi devcontainer"] + api["API :8000"] + kc["Keycloak :8080
realm template-fastapi"] + end + browser --> vite + vite -- "/bff, /api, /.well-known" --> bff + bff -- "redis://valkey:6379" --> valkey + bff -- "BFF_UPSTREAMS crud
(host.docker.internal:8000)" --> api + bff -- "OIDC discovery, token
(host.docker.internal:8080)" --> kc + browser -- "login redirect
localhost:8080" --> kc + kc -- "back-channel logout
host.docker.internal:8787" --> bff +``` + +- **Vite** serves the SPA and proxies `/bff`, `/api` and `/.well-known` to + the BFF without changing `Host` or `Origin`; the BFF checks `Origin` + against `PUBLIC_ORIGIN` (`http://localhost:5173`). +- **The BFF** is `bff/` (a thin app around `@craftainer/bff`). Its dev + environment is in `.devcontainer/compose.instance.yml`. +- **Valkey** (`.devcontainer/stack/valkey/`) holds sessions; it is reached + only from other containers, over plaintext, which is the dev-only opt-out. +- **Keycloak and the API** belong to `template-fastapi` and are reached + through its forwarded ports. The browser is sent to Keycloak at + `localhost:8080`, which is also the `iss` in tokens (`OIDC_ISSUER`), while + the BFF itself reaches it at `host.docker.internal:8080` + (`OIDC_DISCOVERY_URL`). + +Forwarded to the host: 5173 (Vite), 8787 (dev BFF, the back-channel logout +target) and 8081 (the production image, below). + +## Production image + +```mermaid +flowchart LR + browser["Browser
PUBLIC_ORIGIN"] + subgraph image["runner image (node, user node) :8080"] + bff["node src/main.ts
@craftainer/bff + dist/"] + end + store[("Valkey
SESSION_STORE_URL (rediss://)")] + api["Backend API
BFF_UPSTREAMS"] + idp["Identity provider
OIDC_ISSUER"] + browser --> bff + bff --> store + bff --> api + bff <--> idp +``` + +One process serves the built SPA, the `/bff/*` session routes, the +well-known endpoints, `/bff/analytics` and the `/api//*` proxy, +and answers `/health/live` (liveness, no dependencies) and `/health/ready`. +On a developer machine the image is published on host port 8081 +(`-p 8081:8080`), because 8080 is Keycloak there. + +## Release + +`release.yml` runs the template-docker `Makefile`: per-architecture image +build, smoke test (`/health/live`), SBOM, scan and publish; see +[`TEMPLATE.md`](TEMPLATE.md). diff --git a/docs/frs/FR-0001-sign-in-through-the-bff.md b/docs/frs/FR-0001-sign-in-through-the-bff.md new file mode 100644 index 0000000..9d5dbff --- /dev/null +++ b/docs/frs/FR-0001-sign-in-through-the-bff.md @@ -0,0 +1,42 @@ +# FR-0001. Sign in and call the backend through the BFF + +## Status + +Implemented (the real-Keycloak end-to-end test is BF5's). + +## Description + +The system shall let a user sign in with the backend's identity provider +and use the admin without any access or refresh token being available to +the page's JavaScript. It shall also record anonymous page views without +identifying the visitor, and it shall honour a browser's Global Privacy +Control and Do Not Track signals. + +## Source + +The maintainers' decision to use the BFF pattern for the template family +(`docs/plans/2026-10-bff-wellknown-analytics-owasp.md`). No stakeholder +register exists yet. Prompted +[ADR 0007](../adrs/0007-adopt-craftainer-bff.md) and +[ADR 0008](../adrs/0008-spa-analytics-and-consent.md). + +## Acceptance criteria + +- An anonymous visit shows a sign-in prompt; "Sign in" navigates to + `/bff/login` and returns the user to the page they were on. +- After sign-in the browser holds one session cookie that is `Secure`, + `HttpOnly`, `SameSite=Strict`, `__Host-` prefixed, and no token in + storage, cookies readable by script, or responses. +- A request to `/api/crud/*` without `X-CSRF: 1`, or from another origin, is + refused; without a session it answers 401 and the SPA goes to sign-in. +- "Sign out" ends the session server-side and the IdP's session; a logout at + the IdP (back-channel) ends the BFF session too. +- A session that expires or whose refresh is rejected returns the user to + sign-in rather than showing errors. +- The admin lists and edits records the user's roles allow; the backend + remains the only authorisation point. +- `/.well-known/security.txt` and `/.well-known/gpc.json` are served when + configured. +- Navigating the admin sends `page.view` events with paths that contain no + record id, filter or query; with `Sec-GPC: 1` or `DNT: 1` nothing is + recorded. diff --git a/index.html b/index.html index dc02ef0..71d4719 100644 --- a/index.html +++ b/index.html @@ -8,10 +8,6 @@
- - diff --git a/scripts/README.md b/scripts/README.md index a6480e0..1cedbcd 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -24,7 +24,7 @@ setup: group-0 runtime user for arbitrary-UID platforms (run before copying app code, which is then copied read-only), then every `runner-setup.d/*.sh`. An instance whose runner image doesn't call it - (an nginx runner, say) ignores it and `runner-setup.d/`. + (template-react's Node runner, say) ignores it and `runner-setup.d/`. - `image-scan.sh` — the manual-stage `image-scan` hook: Trivy over the tar for the current `RELEASE_VERSION`/arch in `dist/`. - `test-image-scripts.sh` — the `image-scripts-test` hook: offline tests of @@ -43,9 +43,6 @@ - `develop.d/10-corepack.sh` — enables pnpm via Corepack in the devcontainer image; `post-create.d/10-pnpm-install.sh` — installs the app's dependencies. Both template-react's own, run by the hooks above. -- `docker/` — the runtime image's own assets: `nginx.conf.template` and - `docker-entrypoint.d/20-envsubst-app-config.sh`, both `COPY`'d into - the top-level `Dockerfile`'s `runner` stage. ## Do diff --git a/scripts/runner-setup.d/README.md b/scripts/runner-setup.d/README.md index df9ce57..ce5585a 100644 --- a/scripts/runner-setup.d/README.md +++ b/scripts/runner-setup.d/README.md @@ -2,7 +2,7 @@ Optional instance hook directory for the runner stage of the top-level `Dockerfile`; an instance whose runner image doesn't call `../runner-setup.sh` -(template-react's nginx runner, say) leaves it empty and unused. `../runner-setup.sh` (template-owned, `replace` tier) creates +(template-react's Node runner, say) leaves it empty and unused. `../runner-setup.sh` (template-owned, `replace` tier) creates the group-0 runtime user, makes the home directory and `/app/data` group-writable (and nothing else under `/app`), then runs every `*.sh` here in glob order as `bash NN-name.sh APP_UID`, still as root, in the same `RUN` layer. diff --git a/scripts/runner-setup.sh b/scripts/runner-setup.sh index b398071..2610014 100755 --- a/scripts/runner-setup.sh +++ b/scripts/runner-setup.sh @@ -15,7 +15,7 @@ # layer) and the runtime user can't modify it. Other writable directories # (caches, upload dirs) are created by runner-setup.d hooks. # -# Optional: an instance whose runner image needs no such user (an nginx runner +# Optional: an instance whose runner image needs no such user (a Node runner # with its own non-root user) never calls this script. # # Usage: runner-setup.sh APP_UID diff --git a/src/App.test.tsx b/src/App.test.tsx index 8f9d8b6..de69d50 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -4,11 +4,21 @@ import App from "./App"; const { useAuthMock } = vi.hoisted(() => ({ useAuthMock: vi.fn() })); vi.mock("./auth/authContext", () => ({ useAuth: useAuthMock })); +const { usePageViewsMock } = vi.hoisted(() => ({ usePageViewsMock: vi.fn() })); +vi.mock("./analytics/pageViews", () => ({ usePageViews: usePageViewsMock })); describe("App", () => { // Vitest globals are off, so Testing Library does not clean up on its own. afterEach(cleanup); + it("reports page views", () => { + useAuthMock.mockReturnValue({ isLoading: false, isAuthenticated: false }); + + render(); + + expect(usePageViewsMock).toHaveBeenCalled(); + }); + it("prompts sign-in when unauthenticated", () => { useAuthMock.mockReturnValue({ isLoading: false, isAuthenticated: false }); diff --git a/src/App.tsx b/src/App.tsx index 8c14020..9ad032f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,12 +6,17 @@ import Container from "@mui/material/Container"; import Stack from "@mui/material/Stack"; import Toolbar from "@mui/material/Toolbar"; import Typography from "@mui/material/Typography"; +import { usePageViews } from "./analytics/pageViews"; import { useAuth } from "./auth/authContext"; import { HealthStatus } from "./components/HealthStatus"; import { AdminPage } from "./pages/AdminPage"; function App() { const auth = useAuth(); + // One page view per distinct view, signed in or not (ADR 0008). The BFF + // decides whether it is recorded (GPC/DNT, consent), so there is no consent + // UI here. + usePageViews(); return ( <> diff --git a/src/public/config.js.template b/src/public/config.js.template deleted file mode 100644 index 1926bef..0000000 --- a/src/public/config.js.template +++ /dev/null @@ -1,9 +0,0 @@ -// Generated at container start (see scripts/docker/docker-entrypoint.d/20-envsubst-app-config.sh) -// from real env vars, into /config.js -- loaded by index.html before the -// app bundle. See docs/adrs/0002-same-origin-api-proxy.md's "Runtime- -// configurable production image". Never present as `/config.js` in dev -- -// src/auth/config.ts falls back to Vite's own build-time env vars there. -window.__APP_CONFIG__ = { - oidcAuthority: "${OIDC_AUTHORITY}", - oidcClientId: "${OIDC_CLIENT_ID}", -}; From 8fcd75b0de360af273808647fa81f208f307cfd6 Mon Sep 17 00:00:00 2001 From: crazyracer98 <8631139+crazyracer98@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:46:56 +0000 Subject: [PATCH 2/9] test(e2e): drive the BFF through a real Keycloak login, Valkey and API (BF5) Session cookie, CSRF/Origin, logout, back-channel logout, refresh CAS, idle/absolute expiry, well-known, analytics, admin journey; lane.sh/run.sh/lane.compose.yml; docs --- docs/adrs/0006-manifest-driven-admin.md | 17 +- docs/architecture.md | 35 +++ tests/e2e/README.md | 124 ++++++-- tests/e2e/admin.e2e.ts | 54 ++-- tests/e2e/analytics.e2e.ts | 300 ++++++++++++++++++ tests/e2e/backchannel.e2e.ts | 112 +++++++ tests/e2e/expiry.e2e.ts | 76 +++++ tests/e2e/global-setup.ts | 45 +++ tests/e2e/lane.compose.yml | 29 ++ tests/e2e/lane.sh | 47 +++ tests/e2e/lane.ts | 386 ++++++++++++++++++++++++ tests/e2e/playwright.config.ts | 104 +++++-- tests/e2e/refresh.e2e.ts | 124 ++++++++ tests/e2e/run.sh | 51 +++- tests/e2e/session.e2e.ts | 368 ++++++++++++++++++++++ tests/e2e/tsconfig.json | 20 ++ tests/e2e/wellknown.e2e.ts | 94 ++++++ 17 files changed, 1882 insertions(+), 104 deletions(-) create mode 100644 tests/e2e/analytics.e2e.ts create mode 100644 tests/e2e/backchannel.e2e.ts create mode 100644 tests/e2e/expiry.e2e.ts create mode 100644 tests/e2e/global-setup.ts create mode 100644 tests/e2e/lane.compose.yml create mode 100755 tests/e2e/lane.sh create mode 100644 tests/e2e/lane.ts create mode 100644 tests/e2e/refresh.e2e.ts create mode 100644 tests/e2e/session.e2e.ts create mode 100644 tests/e2e/tsconfig.json create mode 100644 tests/e2e/wellknown.e2e.ts diff --git a/docs/adrs/0006-manifest-driven-admin.md b/docs/adrs/0006-manifest-driven-admin.md index bd4732a..df16675 100644 --- a/docs/adrs/0006-manifest-driven-admin.md +++ b/docs/adrs/0006-manifest-driven-admin.md @@ -113,11 +113,11 @@ Four facts about the libraries and the app shaped the wiring: `src/pages/AdminPage.test.tsx`). Stories cover the admin's states against a fake backend built from a captured real manifest (`src/pages/adminStub.ts`), so the real SDK and views run in them. - `tests/e2e/` runs one journey in a real browser against a running - template-fastapi and its Keycloak: sign in, create, conditional edit, a 412 - after an out-of-band change, archive and the trash. It uses the same pinned - Playwright image as the visual suite, serving the built bundle with - `vite preview`. + `tests/e2e/` runs one journey in a real browser against the BFF + ([ADR 0007](0007-adopt-craftainer-bff.md)), which serves the built bundle in + front of a running template-fastapi and its Keycloak: sign in, create, + conditional edit, a 412 after an out-of-band change, archive and the trash. + It uses the same pinned Playwright image as the visual suite. - **Removed.** `HeroesPage`, `src/api/crud.ts` and the hand-written client tests. `src/api/heroes.ts` keeps only the `Hero` type, because `.storybook/stubs.tsx` (template-owned) imports it. @@ -133,9 +133,10 @@ Four facts about the libraries and the app shaped the wiring: - The e2e journey covers inline edit (conditional save, and a refusal after an outside change); jsdom cannot render the DataGrid's rows, so the unit tests cover `rowEtagGetter` and the stories do not edit cells. -- The e2e needs a running template-fastapi (MODE=dev, Postgres and Keycloak) - and Docker for the Playwright image; it is not part of `prek` and runs in the - verification lane (`tests/e2e/README.md`). +- The e2e needs template-fastapi (MODE=dev, with Postgres and Keycloak), a + Valkey and Docker for the Playwright image; `tests/e2e/lane.sh` brings them up. + It is not part of `prek` and runs in the verification lane + (`tests/e2e/README.md`). - Without a router, a record or view is not a path (`/heroes/1`); links are query strings. Adding a router later means replacing `useUrlParams` and passing the router's adapter to the lists. diff --git a/docs/architecture.md b/docs/architecture.md index 7aef352..e4e8006 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -49,6 +49,41 @@ flowchart LR Forwarded to the host: 5173 (Vite), 8787 (dev BFF, the back-channel logout target) and 8081 (the production image, below). +## End-to-end lane + +`tests/e2e/` runs without VS Code, so nothing forwards ports and +`host.docker.internal` is not used. `tests/e2e/lane.sh` starts only +`template-fastapi`'s own compose services (Postgres, S3, MQTT, Valkey, +Keycloak and the API, no Selenium or Umami) in one Compose project, and +`tests/e2e/run.sh` runs the Playwright image in **Keycloak's network +namespace**, so `localhost` is Keycloak for the browser, the BFFs and the +tokens' `iss`. + +```mermaid +flowchart LR + subgraph ns["Keycloak's network namespace (the Playwright container joins it)"] + pw["Playwright + Chromium"] + main["BFF main :8081
serves dist/"] + short["BFF short :5173
6 s idle, 15 s absolute"] + kc["Keycloak :8080"] + end + subgraph proj["template-fastapi compose project (bffe2e)"] + api["API :8000 (myapp)"] + valkey[("redis (Valkey) :6379")] + end + pw --> main + pw --> short + pw -- "login form, admin API" --> kc + main & short -- "sessions (by IP)" --> valkey + main & short -- "/api/crud (by IP)" --> api + kc -- "back-channel logout
localhost:8081" --> main +``` + +The two BFFs are this repo's `bff/` on the two origins the realm's `bff` client +allows (8081 and 5173). The suite sets the `bff` client's back-channel logout URL +to the first for the run and restores it; see +[`tests/e2e/README.md`](../tests/e2e/README.md). + ## Production image ```mermaid diff --git a/tests/e2e/README.md b/tests/e2e/README.md index f283bbb..5ff017c 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -1,39 +1,117 @@ # tests/e2e/ -Playwright end-to-end journey of the manifest-driven admin (ADR 0006), in a -real browser against a running `template-fastapi` and its Keycloak. +Playwright end-to-end tests of the production topology (ADR 0007): the built +SPA served by the BFF (`bff/src/main.ts`, a thin app around +`@craftainer/bff`), signing in through a **real Keycloak** login form, with +sessions in a **real Valkey** and the `/api/crud` proxy in front of a **real +`template-fastapi`**. Nothing is mocked and nothing is token-aware in the +browser. +- `lane.sh` — brings the backing services up and down (below). - `run.sh` — runs the suite in the pinned Playwright image (the one - `tests/visual/run.sh` uses) with `--network host`. The config builds the app - and serves it with `vite preview`, whose `/api` proxy points at - `E2E_API_PROXY_TARGET` (default `http://localhost:8000`). Keycloak's host - name is resolved on the host and handed to the container; the browser signs - in through its real login form as the dev realm's `maintainer`. -- `admin.e2e.ts` — the journey: sign in, create, inline and form edits (both conditional), a 412 after - an out-of-band change, archive and the trash, statistics. -- `playwright.config.ts` — config; `*.e2e.ts` (not `*.spec.ts`, which Vitest - would pick up). + `tests/visual/run.sh` uses). +- `playwright.config.ts` — config. Its `webServer` starts two BFFs from this + repo's `bff/` (`node bff/src/main.ts`, logs to `/tmp/bff-*.log`): `main` on + `http://localhost:8081` (long sessions, `ANALYTICS_BACKEND=log`, + `SECURITY_TXT_CONTACT`, `CHANGE_PASSWORD_URL`) and `short` on + `http://localhost:5173` (`SESSION_IDLE_SECONDS=6`, + `SESSION_ABSOLUTE_SECONDS=15`). Those two origins are the ones the realm's + `bff` client allows as redirect URIs. `*.e2e.ts`, not `*.spec.ts`, which + Vitest would pick up. +- `global-setup.ts` — before the run, sets the realm's access-token lifespan to + 40 s (the BFF refreshes within 30 s of expiry) and points the `bff` client's + back-channel logout URL at the BFF under test; afterwards it restores both. +- `lane.ts` — helpers: sign-in through the Keycloak form, the session cookie, a + bare RESP client for the lane's Valkey (`GET sess:`), the BFF log + reader, Keycloak's admin API. +- `lane.compose.yml` — the override that makes template-fastapi's `myapp` run the + API. +- The specs, by what they settle: + - `session.e2e.ts` — the `__Host-Http-session` cookie's attributes (`HttpOnly`, + `Secure`, `SameSite=Strict`, `Path=/`, no `Domain`), that the browser holds no + token, `GET /bff/user`, the security headers and SPA fallback, 401s and a + forged cookie, a missing `X-CSRF` (403 `csrf_rejected`), a foreign `Origin` or + `Sec-Fetch-Site` (403 `origin_rejected`), no CORS, and logout (cookie cleared, + session gone from Valkey, the refresh token revoked at Keycloak, the Keycloak + SSO session ended). + - `backchannel.e2e.ts` — an admin logout of a user at Keycloak ends that user's + BFF sessions (two browsers, two `sid`s) through the signed logout token, and + the endpoint refuses anything unsigned without needing `X-CSRF`. + - `refresh.e2e.ts` — an expiring access token is refreshed once under eight + racing requests: `sess:` goes from `version` 0 to 1 (the Lua + compare-and-set against Valkey), with rotated tokens and the lock released; a + refresh Keycloak rejects ends the session. + - `expiry.e2e.ts` — the idle timeout (a sliding Valkey TTL) and the absolute + timeout, on the `short` BFF. + - `wellknown.e2e.ts` — `security.txt`, `change-password` (302), `gpc.json`, + `api-catalog`, the JSON 404, `/bff/config`, `/health/*`. + - `analytics.e2e.ts` — `POST /bff/analytics`: empty 202 for an accepted and for a + dropped event (`Sec-GPC: 1`, `DNT: 1`, consent cookie `0`), 400 for a prop key off + the allowlist and other malformed events, 413 above 16 KB, 403 without + `X-CSRF`; and the SPA's page views carry bare paths such as + `/heroes/trash`, never ids or queries. + - `admin.e2e.ts` — the manifest-driven admin (ADR 0006) through the BFF: create, + conditional inline and form edits, a 412 after an out-of-band change, archive + and the trash, statistics. -## Running it +## How the lane is built -Start `template-fastapi` first (`MODE=dev`, its Postgres and Keycloak up, a -database you may write to), then: +No stack service is started that a repo's own compose files do not define, and +none beside them: + +| Piece | From | +| ----------------------------------- | -------------------------------------------------------------------------------------------------- | +| Keycloak (realm `template-fastapi`) | template-fastapi's `.devcontainer/stack/keycloak` | +| Postgres, S3, MQTT | template-fastapi's stack (the API needs them) | +| Valkey (`redis://:6379`) | template-fastapi's `redis` stack service: the same image and command as this repo's `stack/valkey` | +| The API (`MODE=dev`, `uvicorn`) | template-fastapi's `myapp` service, with `lane.compose.yml` on top | +| Selenium, Umami | not started | +| The BFFs and the browser | this repo, in the Playwright image | + +`lane.sh up` runs `docker compose` over template-fastapi's +`.devcontainer/compose.yml` and `compose.instance.yml` plus `lane.compose.yml` +with `--no-deps` (its `depends_on` also lists Selenium). The suite's container +joins **Keycloak's network namespace** (`--network container:`), so +`localhost:8080` is Keycloak for the browser, the BFFs and the tokens' `iss` +alike, and Keycloak can call the BFF's back-channel logout on `localhost:8081`. +A lane has no VS Code to forward ports, which is why this repo's +devcontainer setup (`host.docker.internal`) is not reused. The API and Valkey are +addressed by container IP, which `run.sh` looks up. ```sh -E2E_API_PROXY_TARGET=http://localhost:8000 tests/e2e/run.sh +# 1. The SPA the BFF serves. +pnpm exec vite build +# 2. A clean, git-only checkout of template-fastapi's main (the realm with the +# `bff` client is in it), outside any workspace that is in use. +E2E_FASTAPI_DIR=/path/to/template-fastapi-checkout tests/e2e/lane.sh up +# 3. The suite. +tests/e2e/run.sh # extra arguments go to `playwright test` +# 4. Everything down: containers, volumes and the built API image. +E2E_FASTAPI_DIR=/path/to/template-fastapi-checkout tests/e2e/lane.sh down ``` -The backend must be configured with the same Keycloak host name the browser -uses (`http://keycloak:8080/realms/template-fastapi`, the default of -`E2E_OIDC_AUTHORITY`), because it checks the token's issuer. The journey -creates and archives one record per run and never deletes or truncates -anything else. +The lane needs about 2 GB of memory (Keycloak alone is about 0.6 GB; the API, +Postgres, RustFS, Valkey, Mosquitto and the Playwright container with Chromium +make up the rest), so keep other heavy work off the machine while it runs. The +suite itself takes roughly 5 minutes; the first `lane.sh up` also builds +template-fastapi's devcontainer image and runs `uv sync`. The journeys create and +archive one hero per run and never delete or truncate anything else; `down -v` +discards the database anyway. ## Do -- Keep it to journeys that need the real backend; component states belong in - stories and `src/**/*.test.tsx`. +- Keep it to journeys that need the real Keycloak, Valkey or backend; component + states belong in stories and `src/**/*.test.tsx`, and the BFF's own logic in + `@craftainer/bff`'s tests. +- Use `raw()` from `lane.ts` for requests that must control every header + (`Cookie`, `Origin`, `Sec-Fetch-Site`, `X-CSRF`) instead of Playwright's cookie + jar, and `idle(page)` before waiting on session timeouts: the SPA polls, and a + poll counts as activity. +- Give each spec its own dev-realm user where it ends sessions at Keycloak (the + back-channel spec owns `viewer`). ## Don't -- Run it in `prek` or the unit-test job: it needs the backend and Docker. +- Run it in `prek` or the unit-test job: it needs Docker and a lane. +- Work around a defect of `@craftainer/bff` here or in `bff/`. A failing test that + shows one goes back to `lib-bff-ts` as a fix and a patch release. diff --git a/tests/e2e/admin.e2e.ts b/tests/e2e/admin.e2e.ts index 2b917f9..b460f48 100644 --- a/tests/e2e/admin.e2e.ts +++ b/tests/e2e/admin.e2e.ts @@ -1,42 +1,31 @@ -// One journey through the manifest-driven admin, signed in as the dev realm's -// maintainer (password == username) against a real template-fastapi: sign in +// One journey through the manifest-driven admin (ADR 0006), signed in as the +// dev realm's maintainer (password == username) through the BFF: sign in // through Keycloak, create, edit with a conditional save, hit a 412 after an -// out-of-band change, archive and see the record in the trash. +// out-of-band change, archive and see the record in the trash. The browser +// only ever talks to the BFF; the out-of-band changes go to the backend +// directly with a bearer token. import { expect, test, type APIRequestContext, type Page, } from "@playwright/test"; +import { + HEROES_DIRECT, + MAIN, + backendToken, + laneEnv, + submitKeycloakForm, +} from "./lane"; -const authority = - process.env.E2E_OIDC_AUTHORITY ?? - "http://keycloak:8080/realms/template-fastapi"; -const HEROES = "/api/crud/v1/heroes/v2/json"; +const bearer = (request: APIRequestContext, username: string) => + backendToken(request, username); async function signIn(page: Page, username: string) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.goto(MAIN.origin, { waitUntil: "domcontentloaded" }); await page.getByRole("button", { name: "Sign in" }).click(); - await page.locator("#username").fill(username); - await page.locator("#password").fill(username); - await page.locator("#kc-login").click(); - await expect(page.getByText(username, { exact: true })).toBeVisible(); -} - -async function bearer(request: APIRequestContext, username: string) { - const response = await request.post( - `${authority}/protocol/openid-connect/token`, - { - form: { - grant_type: "password", - client_id: "api", - username, - password: username, - }, - }, - ); - expect(response.ok()).toBeTruthy(); - return `Bearer ${(await response.json()).access_token as string}`; + await submitKeycloakForm(page, username); + await expect(page.getByRole("button", { name: "Sign out" })).toBeVisible(); } test.setTimeout(180_000); @@ -83,12 +72,13 @@ test("the admin creates, edits, conflicts and archives a record", async ({ // Someone else changes the record after the list loaded: the next inline // edit is refused and sends nothing. + const heroes = `${laneEnv("E2E_API_URL")}${HEROES_DIRECT}`; const found = await request.get( - `${HEROES}?name=${encodeURIComponent(inlineName)}`, + `${heroes}?name=${encodeURIComponent(inlineName)}`, { headers: { Authorization: await bearer(request, "maintainer") } }, ); - const rowId = ((await found.json()) as { id: number }[])[0].id; - const elsewhere = await request.patch(`${HEROES}?id=${rowId}`, { + const rowId = ((await found.json()) as { id: number }[])[0]!.id; + const elsewhere = await request.patch(`${heroes}?id=${rowId}`, { headers: { Authorization: await bearer(request, "maintainer") }, data: { power_level: 7 }, }); @@ -130,7 +120,7 @@ test("the admin creates, edits, conflicts and archives a record", async ({ await expect(field).toHaveValue(`${name} edited`); // Change the record behind the UI's back: the next save must be refused. - const patched = await request.patch(`${HEROES}?id=${id}`, { + const patched = await request.patch(`${heroes}?id=${id}`, { headers: { Authorization: await bearer(request, "maintainer") }, data: { power_level: 99 }, }); diff --git a/tests/e2e/analytics.e2e.ts b/tests/e2e/analytics.e2e.ts new file mode 100644 index 0000000..38294f1 --- /dev/null +++ b/tests/e2e/analytics.e2e.ts @@ -0,0 +1,300 @@ +// The analytics intake (ADR 0008) against the real BFF, with the `log` +// backend so every recorded event is a line the specs can read. The BFF +// answers 202 for a recorded and a dropped event alike; what differs is only +// whether a line shows up, and the specs wait for a later "control" event +// before concluding that an earlier one was dropped. +import { expect, test } from "@playwright/test"; +import { + CONSENT_COOKIE, + MAIN, + loggedEvents, + raw, + setCookies, + signInViaBff, + sleep, + until, +} from "./lane"; + +let counter = 0; +/** A unique, valid event name (`e2e._`). */ +const eventName = (kind: string) => + `e2e.${kind}_${Date.now().toString(36)}${(counter += 1)}`; + +const recorded = (name: string) => + until( + () => loggedEvents(MAIN.log).filter((e) => e.name === name), + (events) => events.length > 0, + ); + +/** Sends a control event with the given headers and waits for it to be logged. */ +async function control(headers: Record = {}) { + const api = await raw(MAIN.origin, {}, { "X-CSRF": "1" }); + const name = eventName("control"); + expect( + (await api.post("/bff/analytics", { data: { name }, headers })).status(), + ).toBe(202); + expect(await recorded(name)).toHaveLength(1); + await api.dispose(); +} + +test.describe("POST /bff/analytics", () => { + test("an event is accepted with an empty 202 and recorded", async () => { + const api = await raw(MAIN.origin, {}, { "X-CSRF": "1" }); + const name = eventName("recorded"); + const response = await api.post("/bff/analytics", { + data: { + name, + url: "/admin/heroes/trash?id=7#frag", + props: { powers_count: 3 }, + }, + }); + expect(response.status()).toBe(202); + expect(await response.text()).toBe(""); + const [event] = await recorded(name); + // Query and fragment are stripped; the allowlisted prop is kept. + expect(event).toEqual({ + name, + url: "/admin/heroes/trash", + props: { powers_count: 3 }, + }); + await api.dispose(); + }); + + test("a batch of events is accepted", async () => { + const api = await raw(MAIN.origin, {}, { "X-CSRF": "1" }); + const names = [eventName("batch"), eventName("batch")]; + const response = await api.post("/bff/analytics", { + data: names.map((name) => ({ name })), + }); + expect(response.status()).toBe(202); + for (const name of names) expect(await recorded(name)).toHaveLength(1); + await api.dispose(); + }); + + test("Sec-GPC: 1 and DNT: 1 are answered 202 and dropped", async () => { + const api = await raw(MAIN.origin, {}, { "X-CSRF": "1" }); + const gpc = eventName("gpc"); + const dnt = eventName("dnt"); + for (const [name, headers] of [ + [gpc, { "Sec-GPC": "1" }], + [dnt, { DNT: "1" }], + ] as const) { + const response = await api.post("/bff/analytics", { + data: { name }, + headers, + }); + expect(response.status()).toBe(202); + expect(await response.text()).toBe(""); + } + // The policy is checked before validation: even garbage gets 202 under GPC. + const garbage = await api.post("/bff/analytics", { + data: { not: "an event" }, + headers: { "Sec-GPC": "1" }, + }); + expect(garbage.status()).toBe(202); + + await control(); + const names = loggedEvents(MAIN.log).map((e) => e.name); + expect(names).not.toContain(gpc); + expect(names).not.toContain(dnt); + await api.dispose(); + }); + + test("the consent cookie decides: 0 stops recording, 1 records again", async () => { + const api = await raw(MAIN.origin, {}, { "X-CSRF": "1" }); + + const declined = await api.post("/bff/consent", { + data: { analytics: false }, + }); + expect(declined.status()).toBe(200); + expect(await declined.json()).toEqual({ analytics: false }); + const header = setCookies(declined).find((c) => + c.startsWith(`${CONSENT_COOKIE}=0`), + ); + expect(header, "the consent cookie").toBeDefined(); + expect(header).toMatch(/;\s*HttpOnly/i); + expect(header).toMatch(/;\s*Secure/i); + expect(header).toMatch(/;\s*SameSite=Strict/i); + expect(header).toMatch(/;\s*Path=\/(;|$)/i); + expect(header).toMatch(/;\s*Max-Age=\d+/i); + expect(header).not.toMatch(/;\s*Domain=/i); + + const withZero = await raw( + MAIN.origin, + { [CONSENT_COOKIE]: "0" }, + { "X-CSRF": "1" }, + ); + const stopped = eventName("declined"); + expect( + ( + await withZero.post("/bff/analytics", { data: { name: stopped } }) + ).status(), + ).toBe(202); + expect((await withZero.get("/bff/consent")).status()).toBe(200); + expect(await (await withZero.get("/bff/consent")).json()).toEqual({ + analytics: false, + }); + await control(); + expect(loggedEvents(MAIN.log).map((e) => e.name)).not.toContain(stopped); + + const withOne = await raw( + MAIN.origin, + { [CONSENT_COOKIE]: "1" }, + { "X-CSRF": "1" }, + ); + const granted = eventName("granted"); + expect( + ( + await withOne.post("/bff/analytics", { data: { name: granted } }) + ).status(), + ).toBe(202); + expect(await recorded(granted)).toHaveLength(1); + expect(await (await withOne.get("/bff/consent")).json()).toEqual({ + analytics: true, + }); + + expect( + (await api.post("/bff/consent", { data: { analytics: "yes" } })).status(), + ).toBe(400); + await withZero.dispose(); + await withOne.dispose(); + await api.dispose(); + }); + + test("a malformed event or a prop key off the allowlist is a 400", async () => { + const api = await raw(MAIN.origin, {}, { "X-CSRF": "1" }); + const invalid = async (data: unknown) => { + const response = await api.post("/bff/analytics", { data }); + expect(response.status(), JSON.stringify(data).slice(0, 80)).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_event" }); + }; + + const leaked = eventName("leak"); + await invalid({ name: leaked, props: { powers_count: 1, secret: "x" } }); // key off the allowlist + await invalid({ name: "Bad Name" }); + await invalid({ name: "nodots" }); + await invalid({ name: eventName("extra"), extra: true }); // unknown field + await invalid({ + name: eventName("longprop"), + props: { powers_count: "x".repeat(201) }, + }); + await invalid({ + name: eventName("nested"), + props: { powers_count: { a: 1 } }, + }); + await invalid({ name: eventName("url"), url: "//evil.example/x" }); + await invalid({ name: eventName("url"), url: "relative" }); + await invalid( + Array.from({ length: 51 }, () => ({ name: eventName("many") })), // at most 50 + ); + await invalid([]); + + await control(); + expect(loggedEvents(MAIN.log).map((e) => e.name)).not.toContain(leaked); + await api.dispose(); + }); + + test("a body over 16 KB is a 413", async () => { + const api = await raw(MAIN.origin, {}, { "X-CSRF": "1" }); + const response = await api.post("/bff/analytics", { + data: { name: eventName("big"), url: `/${"a".repeat(17_000)}` }, + }); + expect(response.status()).toBe(413); + await api.dispose(); + }); + + test("it is a CSRF-guarded /bff route: no X-CSRF or a foreign Origin is a 403", async () => { + const api = await raw(MAIN.origin); + const name = eventName("csrf"); + const noHeader = await api.post("/bff/analytics", { data: { name } }); + expect(noHeader.status()).toBe(403); + expect(await noHeader.json()).toEqual({ error: "csrf_rejected" }); + const foreign = await api.post("/bff/analytics", { + data: { name }, + headers: { "X-CSRF": "1", Origin: "https://evil.example" }, + }); + expect(foreign.status()).toBe(403); + expect(await foreign.json()).toEqual({ error: "origin_rejected" }); + await control(); + expect(loggedEvents(MAIN.log).map((e) => e.name)).not.toContain(name); + await api.dispose(); + }); +}); + +test.describe("page views from the SPA", () => { + test("the SPA reports bare paths: no ids, queries or personal data", async ({ + page, + }) => { + const sent: { url: string; body: unknown; status: number }[] = []; + page.on("response", (response) => { + const request = response.request(); + if (new URL(response.url()).pathname === "/bff/analytics") { + sent.push({ + url: request.url(), + body: request.postDataJSON() as unknown, + status: response.status(), + }); + } + }); + + // Signed out first: the page view does not need a session. + await page.goto(MAIN.origin, { waitUntil: "domcontentloaded" }); + await expect.poll(() => sent.length).toBeGreaterThan(0); + + await signInViaBff(page, MAIN.origin, "maintainer", "/"); + await expect(page.getByRole("heading", { name: "Heroes" })).toBeVisible(); + await page.getByRole("tab", { name: "Trash" }).click(); + await expect( + page.getByRole("tab", { name: "Trash", selected: true }), + ).toBeVisible(); + await expect + .poll(() => + sent.some((s) => (s.body as { url?: string }).url?.endsWith("/trash")), + ) + .toBe(true); + await sleep(500); + + // Every request was accepted, carried only a name and a bare path, and + // (with the `log` backend) was recorded the way the browser sent it. + expect(sent.every((s) => s.status === 202)).toBe(true); + for (const { body } of sent) { + expect(Object.keys(body as object).sort()).toEqual(["name", "url"]); + const { name, url } = body as { name: string; url: string }; + expect(name).toBe("page.view"); + expect(url).toMatch(/^\/[A-Za-z0-9_/-]*$/); // no query, fragment or id + } + const urls = sent.map((s) => (s.body as { url: string }).url); + expect(urls.some((u) => u.endsWith("/trash"))).toBe(true); + expect(urls.some((u) => u.endsWith("/record"))).toBe(false); + + const events = await until( + () => loggedEvents(MAIN.log).filter((e) => e.name === "page.view"), + (list) => list.some((e) => e.url.endsWith("/trash")), + ); + expect(events.some((e) => e.url.endsWith("/trash"))).toBe(true); + expect(events.every((e) => !/[?#=\d]/.test(e.url))).toBe(true); + }); + + test("a browser that sends Global Privacy Control makes no request at all", async ({ + browser, + }) => { + const context = await browser.newContext(); + await context.addInitScript(() => { + Object.defineProperty(Navigator.prototype, "globalPrivacyControl", { + get: () => true, + }); + }); + const page = await context.newPage(); + const sent: string[] = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/bff/analytics") { + sent.push(request.url()); + } + }); + await page.goto(MAIN.origin, { waitUntil: "domcontentloaded" }); + await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible(); + await sleep(1_000); // time enough for the page view the opt-out must suppress + expect(sent).toEqual([]); + await context.close(); + }); +}); diff --git a/tests/e2e/backchannel.e2e.ts b/tests/e2e/backchannel.e2e.ts new file mode 100644 index 0000000..0f2c830 --- /dev/null +++ b/tests/e2e/backchannel.e2e.ts @@ -0,0 +1,112 @@ +// OIDC Back-Channel Logout 1.0 against the real Keycloak: an admin ends a +// user's sessions at the IdP, Keycloak POSTs a signed logout token to the BFF +// (the URL global-setup.ts configured), and the BFF ends its own sessions. +// This also settles the logout token's `typ` header and `sid` claim, which the +// BFF's unit tests could only assume. +import { expect, test } from "@playwright/test"; +import { + MAIN, + SESSION_COOKIE, + idle, + keycloakAdmin, + logLines, + raw, + sessionId, + signInViaBff, + storedSession, + valkey, +} from "./lane"; + +test("an admin logout at Keycloak ends the BFF session", async ({ + browser, +}) => { + // Two browsers, one user: each has its own Keycloak session and its own `sid`. + const first = await browser.newContext(); + const second = await browser.newContext(); + const pageA = await first.newPage(); + const pageB = await second.newPage(); + await signInViaBff(pageA, MAIN.origin, "viewer"); + await signInViaBff(pageB, MAIN.origin, "viewer"); + const idA = await sessionId(first); + const idB = await sessionId(second); + await idle(pageA); + await idle(pageB); + + const live = await storedSession(idA); + expect(live?.sid, "the ID token carries a sid").toBeTruthy(); + expect((await storedSession(idB))?.sid).not.toBe(live?.sid); + const [members] = await valkey(["SCARD", `sid:${live!.sid}`]); + expect(members).toBe(1); // the index back-channel logout resolves by + + const before = logLines(MAIN.log).filter( + (l) => l.event === "backchannel_logout", + ).length; + + const admin = await keycloakAdmin(); + await admin.logoutUser("viewer"); + await admin.dispose(); + + // Keycloak delivers the tokens asynchronously. + await expect + .poll( + async () => + (await storedSession(idA)) === null && + (await storedSession(idB)) === null, + { timeout: 15_000 }, + ) + .toBe(true); + expect(await storedSession(idA)).toBeNull(); + expect(await storedSession(idB)).toBeNull(); + + for (const id of [idA, idB]) { + const api = await raw(MAIN.origin, { [SESSION_COOKIE]: id }); + expect((await api.get("/bff/user")).status()).toBe(401); + await api.dispose(); + } + const events = logLines(MAIN.log).filter( + (l) => l.event === "backchannel_logout", + ); + expect(events.length - before).toBe(2); + expect(events.slice(before).every((e) => e.sessions === 1)).toBeTruthy(); + + await first.close(); + await second.close(); +}); + +test("the endpoint is exempt from X-CSRF but refuses anything unsigned", async () => { + const api = await raw(MAIN.origin); + // No X-CSRF and no Origin: the exemption means 400 (bad token), not 403. + const missing = await api.post("/bff/backchannel-logout", { form: {} }); + expect(missing.status()).toBe(400); + expect(await missing.json()).toEqual({ error: "invalid_request" }); + + const garbage = await api.post("/bff/backchannel-logout", { + form: { logout_token: "not.a.jwt" }, + }); + expect(garbage.status()).toBe(400); + + // A well-formed but unsigned token is not accepted either. + const header = Buffer.from('{"alg":"none","typ":"logout+jwt"}').toString( + "base64url", + ); + const claims = Buffer.from( + JSON.stringify({ + iss: "http://localhost:8080/realms/template-fastapi", + aud: "bff", + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 60, + jti: "e2e-unsigned", + sid: "does-not-matter", + events: { "http://schemas.openid.net/event/backchannel-logout": {} }, + }), + ).toString("base64url"); + const unsigned = await api.post("/bff/backchannel-logout", { + form: { logout_token: `${header}.${claims}.` }, + }); + expect(unsigned.status()).toBe(400); + expect( + logLines(MAIN.log).filter((l) => l.event === "backchannel_logout_rejected") + .length, + ).toBeGreaterThanOrEqual(3); + await api.dispose(); +}); diff --git a/tests/e2e/expiry.e2e.ts b/tests/e2e/expiry.e2e.ts new file mode 100644 index 0000000..50747b4 --- /dev/null +++ b/tests/e2e/expiry.e2e.ts @@ -0,0 +1,76 @@ +// Idle and absolute session timeouts, against the second BFF (config: SHORT) +// whose SESSION_IDLE_SECONDS / SESSION_ABSOLUTE_SECONDS are a few seconds, so +// the real timeouts can be waited out. The store entry is a real Valkey key +// with a real TTL, which is what ends an idle session. +import { expect, test } from "@playwright/test"; +import { + SESSION_COOKIE, + SHORT, + idle, + raw, + sessionId, + signInViaBff, + sleep, + storedSession, + valkey, +} from "./lane"; + +test("an idle session ends after SESSION_IDLE_SECONDS, and activity keeps it alive", async ({ + page, + context, +}) => { + test.setTimeout(90_000); + await signInViaBff(page, SHORT.origin, "maintainer"); + const id = await sessionId(context); + await idle(page); // the SPA polls, and a poll would count as activity + const api = await raw(SHORT.origin, { [SESSION_COOKIE]: id }); + + // The key's TTL is the idle timeout (the absolute one is further away). + const [ttl] = await valkey(["PTTL", `sess:${id}`]); + expect(Number(ttl)).toBeGreaterThan(0); + expect(Number(ttl)).toBeLessThanOrEqual(SHORT.idleSeconds * 1000); + + // Sliding: a request every few seconds outlives several idle periods... + const startedAt = Date.now(); + while (Date.now() - startedAt < (SHORT.idleSeconds + 3) * 1000) { + expect((await api.get("/bff/user")).status()).toBe(200); + await sleep(2_000); + } + + // ...and silence does not. + await sleep((SHORT.idleSeconds + 2) * 1000); + expect((await api.get("/bff/user")).status()).toBe(401); + expect(await storedSession(id)).toBeNull(); + await api.dispose(); +}); + +test("a session ends SESSION_ABSOLUTE_SECONDS after login, however active it is", async ({ + page, + context, +}) => { + test.setTimeout(90_000); + await signInViaBff(page, SHORT.origin, "editor"); + const id = await sessionId(context); + await idle(page); + const api = await raw(SHORT.origin, { [SESSION_COOKIE]: id }); + + const created = await storedSession(id); + expect(created!.absoluteExpiresAt - created!.createdAt).toBe( + SHORT.absoluteSeconds, + ); + const user = (await (await api.get("/bff/user")).json()) as { + expiresAt: number; + }; + expect(user.expiresAt).toBe(created!.absoluteExpiresAt); + + // Stay active until past the absolute end: the session still ends. + const endsAt = created!.absoluteExpiresAt * 1000; + while (Date.now() < endsAt + 1_000) { + await api.get("/bff/user"); + await sleep(2_000); + } + const response = await api.get("/bff/user"); + expect(response.status()).toBe(401); + expect(await storedSession(id)).toBeNull(); + await api.dispose(); +}); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 0000000..5cb61b2 --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,45 @@ +// Prepares the lane's Keycloak for the run and puts it back afterwards: +// +// - the realm's access-token lifespan drops to ACCESS_TOKEN_SECONDS, so the +// BFF's refresh (30 s before expiry) happens within seconds of a login; +// - the `bff` client's back-channel logout URL points at the BFF this suite +// starts. The realm's own value is `host.docker.internal:8787` (the dev BFF +// on the developer's machine), which a lane cannot reach, whereas Keycloak +// and the suite share one network namespace here, so `localhost` works. +import { ACCESS_TOKEN_SECONDS, MAIN, keycloakAdmin } from "./lane"; + +export default async function globalSetup() { + const admin = await keycloakAdmin(); + const realm = await admin.realm(); + const originalLifespan = realm.accessTokenLifespan; + + const client = await admin.client("bff"); + const attributes = (client.attributes ?? {}) as Record; + const originalLogoutUrl = attributes["backchannel.logout.url"]; + + await admin.updateRealm({ accessTokenLifespan: ACCESS_TOKEN_SECONDS }); + await admin.updateClient({ + ...client, + attributes: { + ...attributes, + "backchannel.logout.url": `${MAIN.origin}/bff/backchannel-logout`, + }, + }); + await admin.dispose(); + + return async () => { + const again = await keycloakAdmin(); + const current = await again.client("bff"); + await again.updateRealm({ accessTokenLifespan: originalLifespan }); + await again.updateClient({ + ...current, + attributes: { + ...((current.attributes ?? {}) as Record), + ...(originalLogoutUrl === undefined + ? {} + : { "backchannel.logout.url": originalLogoutUrl }), + }, + }); + await again.dispose(); + }; +} diff --git a/tests/e2e/lane.compose.yml b/tests/e2e/lane.compose.yml new file mode 100644 index 0000000..65f019c --- /dev/null +++ b/tests/e2e/lane.compose.yml @@ -0,0 +1,29 @@ +# Compose override for the e2e lane, applied on top of template-fastapi's own +# .devcontainer/compose.yml + compose.instance.yml (tests/e2e/lane.sh passes +# all three). It defines no service of its own: it only makes template-fastapi's +# `myapp` (its devcontainer image, normally `sleep infinity`) run the API, so the +# lane needs no VS Code, no devcontainer CLI and no Docker-in-Docker. +services: + myapp: + # What template-fastapi's post-create script and launch config do by hand: + # install the locked dependencies, then serve (MODE=dev from its compose + # file; migrations run at startup). + command: + - bash + - -c + - uv sync --frozen --extra dev && exec uv run --no-sync uvicorn app.main:app --host 0.0.0.0 --port 8000 + environment: + # Its env_file points the API at the Umami stack service, which the lane + # does not start. + ANALYTICS_BACKEND: none + healthcheck: + test: + - CMD + - python3 + - -c + - import urllib.request; urllib.request.urlopen('http://localhost:8000/health/live', timeout=3) + interval: 5s + timeout: 5s + retries: 60 + # `uv sync` downloads the dependencies on a cold cache. + start_period: 180s diff --git a/tests/e2e/lane.sh b/tests/e2e/lane.sh new file mode 100755 index 0000000..5b40c2f --- /dev/null +++ b/tests/e2e/lane.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Brings up, and tears down, the backing services of the e2e lane: template-fastapi's +# own Postgres, S3, Valkey ("redis"), MQTT and Keycloak stack services plus its API +# (the `myapp` service running uvicorn, see lane.compose.yml). Nothing else is +# started: no Selenium, no Umami, and no second Valkey (template-fastapi's `redis` +# service is the same image and command as this repo's stack/valkey). +# +# E2E_FASTAPI_DIR=/path/to/clean/template-fastapi/checkout tests/e2e/lane.sh up +# tests/e2e/lane.sh down +# +# E2E_FASTAPI_DIR is a git-only clone of template-fastapi's main (the realm with the +# `bff` client is in it since BK1). Compose project name: E2E_LANE_PROJECT +# (default bffe2e). `down` removes the containers, their volumes and the built image. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +project="${E2E_LANE_PROJECT:-bffe2e}" +fastapi="${E2E_FASTAPI_DIR:-}" + +[ -n "$fastapi" ] && [ -f "$fastapi/.devcontainer/compose.yml" ] || { + echo "set E2E_FASTAPI_DIR to a template-fastapi checkout" >&2 + exit 1 +} + +compose=(docker compose -p "$project" + -f "$fastapi/.devcontainer/compose.yml" + -f "$fastapi/.devcontainer/compose.instance.yml" + -f "$here/lane.compose.yml") + +case "${1:-}" in + up) + # --no-deps: myapp's depends_on also lists Selenium, which the lane leaves out. + # Infrastructure first (to healthy), then the API, which needs it at startup. + "${compose[@]}" up -d --wait --no-deps postgres s3 redis mqtt keycloak + "${compose[@]}" up -d --wait --no-deps --build myapp + ;; + down) + "${compose[@]}" down --volumes --remove-orphans --rmi local + ;; + ps) + "${compose[@]}" ps + ;; + *) + echo "usage: $0 up|down|ps" >&2 + exit 2 + ;; +esac diff --git a/tests/e2e/lane.ts b/tests/e2e/lane.ts new file mode 100644 index 0000000..5db282a --- /dev/null +++ b/tests/e2e/lane.ts @@ -0,0 +1,386 @@ +// Shared by the e2e specs and the config: where the lane's pieces are, and the +// small helpers every spec needs (sign-in through the real Keycloak form, the +// session cookie, a raw look into Valkey, the BFF's log, Keycloak's admin API). +// +// The suite runs inside one container that shares Keycloak's network +// namespace (tests/e2e/run.sh), so `localhost` is Keycloak (8080) and the two +// BFFs this config starts (8081, 5173) at once. +import { readFileSync } from "node:fs"; +import { createConnection } from "node:net"; +import { + expect, + request as playwrightRequest, + type APIRequestContext, + type BrowserContext, + type Page, +} from "@playwright/test"; + +/** Keycloak, as the browser and the tokens' `iss` know it. */ +export const KEYCLOAK = "http://localhost:8080"; +export const REALM = "template-fastapi"; +export const REALM_URL = `${KEYCLOAK}/realms/${REALM}`; + +/** The BFF every spec but `expiry` talks to: PUBLIC_ORIGIN and its log. */ +export const MAIN = { + origin: "http://localhost:8081", + port: 8081, + log: process.env.E2E_BFF_LOG ?? "/tmp/bff-main.log", +}; +/** A second BFF with short session timeouts, for `expiry.e2e.ts`. */ +export const SHORT = { + origin: "http://localhost:5173", + port: 5173, + log: process.env.E2E_BFF_SHORT_LOG ?? "/tmp/bff-short.log", + idleSeconds: 6, + absoluteSeconds: 15, +}; + +/** Access-token lifetime the global setup gives the realm (the BFF refreshes within 30 s of expiry). */ +export const ACCESS_TOKEN_SECONDS = 40; +export const REFRESH_SKEW_SECONDS = 30; + +export const SESSION_COOKIE = "__Host-Http-session"; +export const CONSENT_COOKIE = "__Host-Http-consent"; + +/** The backend's address on the lane network and Valkey's, from run.sh. */ +export function laneEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} is not set (see tests/e2e/README.md)`); + return value; +} + +/** A path on the backend through the BFF's `crud` upstream. */ +export const HEROES = "/api/crud/crud/v1/heroes/v2/json"; +/** The same collection on the backend itself (the lane network address). */ +export const HEROES_DIRECT = "/crud/v1/heroes/v2/json"; + +export const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +// ---------------------------------------------------------------- sign-in + +/** + * Signs in through the real Keycloak form: a top-level navigation to + * `/bff/login`, the form, and the redirect back through `/bff/callback`. + * Nothing of the SPA is needed (it is stopped afterwards by `idle`). + */ +export async function signInViaBff( + page: Page, + origin: string, + username: string, + returnTo = "/", +): Promise { + await page.goto( + `${origin}/bff/login?returnTo=${encodeURIComponent(returnTo)}`, + { + waitUntil: "domcontentloaded", + }, + ); + await submitKeycloakForm(page, username); + await page.waitForURL(`${origin}/**`); +} + +/** Fills Keycloak's login form (the dev realm's password equals the username). */ +export async function submitKeycloakForm( + page: Page, + username: string, +): Promise { + await page.locator("#username").fill(username); + await page.locator("#password").fill(username); + await page.locator("#kc-login").click(); +} + +/** Stops whatever the SPA does in the background (it polls, and polls touch the session). */ +export async function idle(page: Page): Promise { + await page.goto("about:blank"); +} + +/** The session cookie as the browser holds it, or undefined. */ +export async function sessionCookie(context: BrowserContext) { + return (await context.cookies()).find((c) => c.name === SESSION_COOKIE); +} + +/** The session id (the cookie value), failing the test when there is none. */ +export async function sessionId(context: BrowserContext): Promise { + const cookie = await sessionCookie(context); + expect(cookie, "a session cookie").toBeDefined(); + return cookie!.value; +} + +/** + * A request context that sends `cookies` verbatim. It does not use + * Playwright's cookie jar, so the `Secure` `__Host-` cookie is sent over plain + * http to localhost whatever the jar's rules are, and a spec controls every + * request header (Origin, Sec-Fetch-Site, X-CSRF). + */ +export async function raw( + baseURL: string, + cookies: Record = {}, + headers: Record = {}, +): Promise { + const cookie = Object.entries(cookies) + .map(([name, value]) => `${name}=${value}`) + .join("; "); + return playwrightRequest.newContext({ + baseURL, + extraHTTPHeaders: { ...(cookie ? { Cookie: cookie } : {}), ...headers }, + }); +} + +/** `Set-Cookie` header values of a response (there can be several). */ +export function setCookies(response: { + headersArray(): { name: string; value: string }[]; +}): string[] { + return response + .headersArray() + .filter((h) => h.name.toLowerCase() === "set-cookie") + .map((h) => h.value); +} + +// ----------------------------------------------------------------- Valkey + +type Reply = string | number | null | Reply[] | { error: string }; + +/** Parses one RESP2 reply from `buffer` at `at`; `undefined` when incomplete. */ +export function parseReply( + buffer: Buffer, + at = 0, +): { reply: Reply; next: number } | undefined { + const eol = buffer.indexOf("\r\n", at); + if (eol === -1) return undefined; + const kind = String.fromCharCode(buffer[at]!); + const line = buffer.toString("utf8", at + 1, eol); + const after = eol + 2; + switch (kind) { + case "+": + return { reply: line, next: after }; + case "-": + return { reply: { error: line }, next: after }; + case ":": + return { reply: Number(line), next: after }; + case "$": { + const length = Number(line); + if (length === -1) return { reply: null, next: after }; + if (buffer.length < after + length + 2) return undefined; + return { + reply: buffer.toString("utf8", after, after + length), + next: after + length + 2, + }; + } + case "*": { + const count = Number(line); + if (count === -1) return { reply: null, next: after }; + const items: Reply[] = []; + let next = after; + for (let i = 0; i < count; i += 1) { + const item = parseReply(buffer, next); + if (item === undefined) return undefined; + items.push(item.reply); + next = item.next; + } + return { reply: items, next }; + } + default: + throw new Error(`unexpected RESP reply type ${kind}`); + } +} + +function encode(args: string[]): string { + return ( + `*${args.length}\r\n` + + args.map((a) => `$${Buffer.byteLength(a)}\r\n${a}\r\n`).join("") + ); +} + +/** + * Runs commands against the lane's real Valkey (the one the BFFs use) over a + * bare RESP2 connection, and returns each command's reply. No client library + * is needed for a handful of GET/PTTL/SET calls. + */ +export async function valkey(...commands: string[][]): Promise { + const url = new URL(laneEnv("E2E_SESSION_STORE_URL")); + return new Promise((resolve, reject) => { + const socket = createConnection({ + host: url.hostname, + port: Number(url.port || 6379), + }); + let received = Buffer.alloc(0); + const replies: Reply[] = []; + let offset = 0; + const fail = (error: Error) => { + socket.destroy(); + reject(error); + }; + socket.setTimeout(5_000, () => fail(new Error("valkey: timeout"))); + socket.on("error", fail); + socket.on("connect", () => { + socket.write(commands.map(encode).join("")); + }); + socket.on("data", (chunk) => { + received = Buffer.concat([received, chunk]); + for (;;) { + const parsed = parseReply(received, offset); + if (parsed === undefined) break; + replies.push(parsed.reply); + offset = parsed.next; + if (replies.length === commands.length) { + socket.end(); + resolve(replies); + return; + } + } + }); + }); +} + +/** What the BFF keeps for a session (`sess:`), or null when it is gone. */ +export interface StoredSession { + sub: string; + sid: string | null; + accessToken: string; + refreshToken: string | null; + accessExpiresAt: number; + absoluteExpiresAt: number; + createdAt: number; + version: number; +} + +export async function storedSession(id: string): Promise { + const [reply] = await valkey(["GET", `sess:${id}`]); + if (reply === null) return null; + if (typeof reply !== "string") throw new Error("unexpected GET reply"); + return JSON.parse(reply) as StoredSession; +} + +// ------------------------------------------------------------------ logs + +/** The BFF's JSON log lines (pino), parsed. */ +export function logLines(path: string): Record[] { + let text = ""; + try { + text = readFileSync(path, "utf8"); + } catch { + return []; + } + const lines: Record[] = []; + for (const line of text.split("\n")) { + if (!line.startsWith("{")) continue; + try { + lines.push(JSON.parse(line) as Record); + } catch { + // a partial last line + } + } + return lines; +} + +/** Analytics events the `log` backend wrote, as `{name, url, props}`. */ +export function loggedEvents(path: string) { + return logLines(path) + .filter((l) => l.msg === "analytics_event") + .map((l) => ({ + name: String(l.analytics_event), + url: String(l.analytics_url), + props: Object.fromEntries( + Object.entries(l) + .filter(([key]) => key.startsWith("analytics_prop_")) + .map(([key, value]) => [key.slice("analytics_prop_".length), value]), + ), + })); +} + +/** Waits until `predicate` holds, polling the log (events are written asynchronously). */ +export async function until( + read: () => T, + predicate: (value: T) => boolean, + timeoutMs = 5_000, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const value = read(); + if (predicate(value)) return value; + if (Date.now() > deadline) return value; + await sleep(100); + } +} + +// -------------------------------------------------------- Keycloak (admin) + +/** An admin-API client for the dev realm (`admin`/`admin`, keycloak.env). */ +export async function keycloakAdmin() { + const api = await playwrightRequest.newContext({ baseURL: KEYCLOAK }); + const token = await api.post("/realms/master/protocol/openid-connect/token", { + form: { + grant_type: "password", + client_id: "admin-cli", + username: "admin", + password: "admin", + }, + }); + expect(token.ok(), "Keycloak admin token").toBeTruthy(); + const bearer = `Bearer ${((await token.json()) as { access_token: string }).access_token}`; + const headers = { Authorization: bearer }; + const admin = `/admin/realms/${REALM}`; + + return { + async realm(): Promise> { + const response = await api.get(admin, { headers }); + expect(response.ok()).toBeTruthy(); + return (await response.json()) as Record; + }, + async updateRealm(patch: Record): Promise { + const response = await api.put(admin, { headers, data: patch }); + expect(response.status(), "update realm").toBe(204); + }, + async client(clientId: string): Promise> { + const response = await api.get(`${admin}/clients`, { + headers, + params: { clientId }, + }); + const found = (await response.json()) as Record[]; + expect(found, `client ${clientId}`).toHaveLength(1); + return found[0]!; + }, + async updateClient(client: Record): Promise { + const response = await api.put(`${admin}/clients/${String(client.id)}`, { + headers, + data: client, + }); + expect(response.status(), "update client").toBe(204); + }, + /** Ends every SSO session of the user: Keycloak then sends the back-channel logouts. */ + async logoutUser(username: string): Promise { + const users = await api.get(`${admin}/users`, { + headers, + params: { username, exact: "true" }, + }); + const found = (await users.json()) as { id: string }[]; + expect(found, `user ${username}`).toHaveLength(1); + const response = await api.post(`${admin}/users/${found[0]!.id}/logout`, { + headers, + }); + expect(response.status(), "admin logout").toBe(204); + }, + dispose: () => api.dispose(), + }; +} + +/** A bearer token for the backend from the dev realm's password-grant client. */ +export async function backendToken( + api: APIRequestContext, + username: string, +): Promise { + const response = await api.post( + `${REALM_URL}/protocol/openid-connect/token`, + { + form: { + grant_type: "password", + client_id: "test-cli", + username, + password: username, + }, + }, + ); + expect(response.ok()).toBeTruthy(); + return `Bearer ${((await response.json()) as { access_token: string }).access_token}`; +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 1df64dc..cd8bb58 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -1,20 +1,51 @@ -// End-to-end tests of the admin against a running template-fastapi and its -// Keycloak (ADR 0006). Run through tests/e2e/run.sh: it uses the same pinned -// Playwright image as tests/visual, and this config starts the app's own Vite -// preview server inside it, proxying /api to the backend. +// End-to-end tests of the production topology (ADR 0007): the built SPA served +// by the BFF (`bff/src/main.ts`), signing in through a real Keycloak, with +// sessions in a real Valkey and the proxy in front of a real template-fastapi. +// Run through tests/e2e/run.sh, which hands in the lane's addresses +// (tests/e2e/README.md). +import { resolve } from "node:path"; import { defineConfig } from "@playwright/test"; +import { MAIN, SHORT, laneEnv, REALM_URL } from "./lane"; -const PORT = 5183; -const apiTarget = process.env.E2E_API_PROXY_TARGET ?? "http://localhost:8000"; -const authority = - process.env.E2E_OIDC_AUTHORITY ?? - "http://keycloak:8080/realms/template-fastapi"; +const root = resolve(import.meta.dirname, "../.."); + +/** Environment of a BFF process: the dev realm's `bff` client, the lane's Valkey and API. */ +function bffEnv(server: { origin: string; port: number }, extra: object = {}) { + return { + PUBLIC_ORIGIN: server.origin, + BFF_PORT: String(server.port), + BFF_HOST: "0.0.0.0", + BFF_STATIC_DIR: resolve(root, "dist"), + // Keycloak is `localhost:8080` for the browser, the tokens' `iss` and + // this process alike (shared network namespace). + OIDC_ISSUER: REALM_URL, + OIDC_DISCOVERY_URL: `${REALM_URL}/.well-known/openid-configuration`, + OIDC_CLIENT_SECRET: + process.env.E2E_BFF_CLIENT_SECRET ?? "dev-only-bff-client-secret", + OIDC_ALLOW_INSECURE: "true", + BFF_UPSTREAMS: JSON.stringify({ + crud: { + url: laneEnv("E2E_API_URL"), + audience: "api", + methods: ["GET", "POST", "PUT", "PATCH", "DELETE"], + }, + }), + SESSION_STORE_URL: laneEnv("E2E_SESSION_STORE_URL"), + SESSION_STORE_ALLOW_PLAINTEXT: "true", + // Many logins and events per minute from one address. + RATE_LIMIT_MAX: "10000", + ANALYTICS_BACKEND: "log", + ANALYTICS_PROP_KEYS: "powers_count", + ...extra, + }; +} export default defineConfig({ testDir: ".", testMatch: "*.e2e.ts", outputDir: "../../test-results", - // One journey creates, edits and deletes records; run it alone. + globalSetup: "./global-setup.ts", + // The specs share one Keycloak and one Valkey; run them one at a time. fullyParallel: false, workers: 1, forbidOnly: !!process.env.CI, @@ -24,25 +55,46 @@ export default defineConfig({ ["html", { outputFolder: "../../playwright-report", open: "never" }], ], use: { - baseURL: `http://127.0.0.1:${PORT}`, locale: "en-US", timezoneId: "UTC", trace: "retain-on-failure", }, - webServer: { - // The production bundle, served by `vite preview` (which reuses - // vite.config.ts's /api proxy): no dependency re-optimisation on a cold - // start, and the test runs what ships. - command: `node_modules/.bin/vite build --logLevel warn && node_modules/.bin/vite preview --port ${PORT} --strictPort --host 127.0.0.1`, - timeout: 600_000, - cwd: "../..", - url: `http://127.0.0.1:${PORT}`, - reuseExistingServer: false, - env: { - VITE_API_PROXY_TARGET: apiTarget, - // The backend validates the token's issuer, so the browser must reach - // Keycloak under the same host name the backend was configured with. - VITE_OIDC_AUTHORITY: authority, + projects: [ + { + name: "main", + testIgnore: "expiry.e2e.ts", + use: { baseURL: MAIN.origin }, }, - }, + { + name: "expiry", + testMatch: "expiry.e2e.ts", + use: { baseURL: SHORT.origin }, + }, + ], + webServer: [ + { + // stdout/stderr go to a file the specs read (analytics events and + // security events are log lines). + command: `node bff/src/main.ts > ${MAIN.log} 2>&1`, + cwd: root, + url: `${MAIN.origin}/health/live`, + timeout: 60_000, + reuseExistingServer: false, + env: bffEnv(MAIN, { + SECURITY_TXT_CONTACT: "mailto:security@example.invalid", + CHANGE_PASSWORD_URL: `${REALM_URL}/account/`, + }), + }, + { + command: `node bff/src/main.ts > ${SHORT.log} 2>&1`, + cwd: root, + url: `${SHORT.origin}/health/live`, + timeout: 60_000, + reuseExistingServer: false, + env: bffEnv(SHORT, { + SESSION_IDLE_SECONDS: String(SHORT.idleSeconds), + SESSION_ABSOLUTE_SECONDS: String(SHORT.absoluteSeconds), + }), + }, + ], }); diff --git a/tests/e2e/refresh.e2e.ts b/tests/e2e/refresh.e2e.ts new file mode 100644 index 0000000..fd2e0df --- /dev/null +++ b/tests/e2e/refresh.e2e.ts @@ -0,0 +1,124 @@ +// Token refresh against the real Keycloak and the real Valkey. Global setup +// gives access tokens ACCESS_TOKEN_SECONDS of life and the BFF refreshes when +// 30 s are left, so a login is stale after ACCESS_TOKEN_SECONDS - 30 s. What +// this settles that the unit tests (an in-memory store) cannot: the Lua +// compare-and-set (`sess:` version 0 -> 1), the refresh lock, and +// Keycloak's refresh-token rotation (revokeRefreshToken, refreshTokenMaxReuse 0). +import { expect, test } from "@playwright/test"; +import { + ACCESS_TOKEN_SECONDS, + HEROES, + MAIN, + REFRESH_SKEW_SECONDS, + SESSION_COOKIE, + idle, + logLines, + raw, + sessionId, + signInViaBff, + sleep, + storedSession, + valkey, +} from "./lane"; + +test("an expiring access token is refreshed once, however many requests race", async ({ + page, + context, +}) => { + test.setTimeout(120_000); + await signInViaBff(page, MAIN.origin, "maintainer"); + const id = await sessionId(context); + await idle(page); + const api = await raw( + MAIN.origin, + { [SESSION_COOKIE]: id }, + { "X-CSRF": "1" }, + ); + + // Fresh: a proxied call does not touch the tokens. + const first = await storedSession(id); + expect(first).toMatchObject({ version: 0 }); + const lifetime = first!.accessExpiresAt - Math.floor(Date.now() / 1000); + expect(lifetime).toBeLessThanOrEqual(ACCESS_TOKEN_SECONDS); + expect(lifetime).toBeGreaterThan(REFRESH_SKEW_SECONDS - 5); // the realm change took effect + expect((await api.get(HEROES)).status()).toBe(200); + expect(await storedSession(id)).toMatchObject({ + version: 0, + accessToken: first!.accessToken, + }); + + // Wait until the token is within the skew, then race several requests. + const wait = + (first!.accessExpiresAt - REFRESH_SKEW_SECONDS) * 1000 - Date.now(); + await sleep(Math.max(0, wait) + 1_500); + const statuses = await Promise.all( + Array.from({ length: 8 }, async () => (await api.get(HEROES)).status()), + ); + expect(statuses).toEqual(Array(8).fill(200)); + + // One refresh won the lock and the compare-and-set: version 0 -> 1, new + // tokens, a rotated refresh token, and the lock released. + const refreshed = await storedSession(id); + expect(refreshed).toMatchObject({ + version: 1, + sub: first!.sub, + sid: first!.sid, + }); + expect(refreshed!.accessToken).not.toBe(first!.accessToken); + expect(refreshed!.refreshToken).not.toBe(first!.refreshToken); + expect(refreshed!.accessExpiresAt).toBeGreaterThan(first!.accessExpiresAt); + const [lock] = await valkey(["EXISTS", `lock:${id}`]); + expect(lock).toBe(0); + + // The session kept its identity and its end: a refresh never extends it. + expect(refreshed!.absoluteExpiresAt).toBeLessThanOrEqual( + first!.absoluteExpiresAt, + ); + expect(refreshed!.createdAt).toBe(first!.createdAt); + expect((await api.get("/bff/user")).status()).toBe(200); + expect(logLines(MAIN.log).some((l) => l.event === "refresh_failed")).toBe( + false, + ); + await api.dispose(); +}); + +test("a refresh Keycloak rejects ends the session", async ({ + page, + context, +}) => { + await signInViaBff(page, MAIN.origin, "editor"); + const id = await sessionId(context); + await idle(page); + const api = await raw( + MAIN.origin, + { [SESSION_COOKIE]: id }, + { "X-CSRF": "1" }, + ); + expect((await api.get(HEROES)).status()).toBe(200); + + // Make the stored token stale and its refresh token one Keycloak never + // issued (an `invalid_grant`), keeping the key's TTL. + const stored = await storedSession(id); + const broken = { + ...stored, + refreshToken: "not-a-refresh-token", + accessExpiresAt: 0, + }; + const [ok] = await valkey([ + "SET", + `sess:${id}`, + JSON.stringify(broken), + "KEEPTTL", + ]); + expect(ok).toBe("OK"); + + const rejected = await api.get(HEROES); + expect(rejected.status()).toBe(401); + expect(await rejected.json()).toEqual({ error: "session_expired" }); + expect(await storedSession(id)).toBeNull(); + expect((await api.get("/bff/user")).status()).toBe(401); + expect(logLines(MAIN.log).some((l) => l.event === "refresh_failed")).toBe( + true, + ); + await api.dispose(); +}); diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index 7d9f581..015ee36 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -1,11 +1,13 @@ #!/usr/bin/env bash -# Runs the admin's end-to-end journey in the pinned Playwright image (the same -# one tests/visual/run.sh uses) against a template-fastapi that is already -# running, and its Keycloak. See README.md for what to start first. +# Runs the BFF end-to-end suite in the pinned Playwright image (the same one +# tests/visual/run.sh uses) against the lane that tests/e2e/lane.sh brought up. # -# E2E_API_PROXY_TARGET=http://localhost:8010 tests/e2e/run.sh +# pnpm exec vite build # the SPA the BFF serves (dist/) +# E2E_FASTAPI_DIR=... tests/e2e/lane.sh up +# tests/e2e/run.sh +# tests/e2e/lane.sh down # -# Extra arguments go to `playwright test`. +# Extra arguments go to `playwright test`. See README.md for the topology. set -euo pipefail # Must equal tests/visual/run.sh's IMAGE (and @playwright/test in package.json). @@ -13,17 +15,36 @@ IMAGE="mcr.microsoft.com/playwright:v1.64.1-noble@sha256:7852dc5a7d72f5fe458a597 cd "$(dirname "$0")/../.." -# The container shares this host's network (`--network host`), so the backend -# is reachable on localhost; Keycloak is resolved here and handed in, because -# its name only exists in the devcontainer's /etc/hosts. -keycloak_host="${E2E_KEYCLOAK_HOST:-keycloak}" -keycloak_ip="$(getent hosts "$keycloak_host" | awk '{print $1; exit}' || true)" -[ -n "$keycloak_ip" ] || { echo "cannot resolve $keycloak_host (set E2E_KEYCLOAK_HOST)" >&2; exit 1; } +[ -f dist/index.html ] || { echo "dist/ is missing: run 'pnpm exec vite build' first" >&2; exit 1; } -exec docker run --rm --ipc=host --init --network host \ - --add-host "keycloak:$keycloak_ip" \ +project="${E2E_LANE_PROJECT:-bffe2e}" + +container_of() { + docker ps -q \ + --filter "label=com.docker.compose.project=$project" \ + --filter "label=com.docker.compose.service=$1" +} +ip_of() { + docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1" +} + +keycloak="$(container_of keycloak)" +api="$(container_of myapp)" +valkey="$(container_of redis)" +[ -n "$keycloak" ] && [ -n "$api" ] && [ -n "$valkey" ] || { + echo "the lane is not up (project $project): run tests/e2e/lane.sh up" >&2 + exit 1 +} + +# The suite shares Keycloak's network namespace: `localhost:8080` is Keycloak, as +# it is for the browser and in the tokens' `iss`, and the two BFFs the config +# starts (8081 and 5173, the origins the realm's `bff` client allows) are on +# `localhost` too, so Keycloak's back-channel logout reaches them. The API and +# Valkey are addressed by IP (no DNS assumption). +exec docker run --rm --ipc=host --init \ + --network "container:$keycloak" \ -u "$(id -u):$(id -g)" -e HOME=/tmp -e CI="${CI:-}" \ - -e E2E_API_PROXY_TARGET="${E2E_API_PROXY_TARGET:-http://localhost:8000}" \ - -e E2E_OIDC_AUTHORITY="${E2E_OIDC_AUTHORITY:-http://keycloak:8080/realms/template-fastapi}" \ + -e E2E_API_URL="http://$(ip_of "$api"):8000" \ + -e E2E_SESSION_STORE_URL="redis://$(ip_of "$valkey"):6379" \ -v "$PWD:$PWD" -w "$PWD" "$IMAGE" \ node_modules/.bin/playwright test --config tests/e2e/playwright.config.ts "$@" diff --git a/tests/e2e/session.e2e.ts b/tests/e2e/session.e2e.ts new file mode 100644 index 0000000..057ef54 --- /dev/null +++ b/tests/e2e/session.e2e.ts @@ -0,0 +1,368 @@ +// The session through a real Keycloak login: the cookie, what the browser +// holds, CSRF and Origin rejections, and logout (ADR 0007, FR-0001). +import { expect, test } from "@playwright/test"; +import { + KEYCLOAK, + MAIN, + REALM_URL, + HEROES, + SESSION_COOKIE, + idle, + logLines, + raw, + sessionCookie, + sessionId, + setCookies, + signInViaBff, + storedSession, + submitKeycloakForm, + valkey, +} from "./lane"; + +test.describe("sign-in and the session cookie", () => { + test("the callback sets a __Host- cookie that is HttpOnly, Secure and SameSite=Strict", async ({ + page, + context, + }) => { + const callbacks: string[][] = []; + page.on("response", (response) => { + if (new URL(response.url()).pathname === "/bff/callback") { + void response + .headersArray() + .then((headers) => + callbacks.push( + headers + .filter((h) => h.name.toLowerCase() === "set-cookie") + .map((h) => h.value), + ), + ); + } + }); + + await signInViaBff(page, MAIN.origin, "maintainer"); + + // The Set-Cookie header on the callback's redirect, attribute by attribute. + await expect.poll(() => callbacks.length).toBeGreaterThan(0); + const header = callbacks + .flat() + .find((c) => c.startsWith(`${SESSION_COOKIE}=`)); + expect(header, "Set-Cookie of the session").toBeDefined(); + expect(header).toMatch(/;\s*Path=\/(;|$)/i); + expect(header).toMatch(/;\s*HttpOnly/i); + expect(header).toMatch(/;\s*Secure/i); + expect(header).toMatch(/;\s*SameSite=Strict/i); + expect(header).not.toMatch(/;\s*Domain=/i); // __Host- forbids it + expect(header).not.toMatch(/;\s*(Max-Age|Expires)=/i); // a browser-session cookie + + // What Chromium stored: Secure is accepted on http://localhost. + const cookie = await sessionCookie(context); + expect(cookie).toMatchObject({ + name: SESSION_COOKIE, + path: "/", + httpOnly: true, + secure: true, + sameSite: "Strict", + domain: "localhost", + }); + // Opaque: 256 random bits, not a JWT or anything derived from one. + expect(cookie!.value).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); + + test("the browser holds no token", async ({ page, context }) => { + await signInViaBff(page, MAIN.origin, "maintainer"); + const id = await sessionId(context); + const stored = await storedSession(id); + expect(stored?.accessToken).toBeTruthy(); + + const visible = await page.evaluate(() => ({ + cookie: document.cookie, + local: JSON.stringify({ ...localStorage }), + session: JSON.stringify({ ...sessionStorage }), + })); + expect(visible.cookie).not.toContain(SESSION_COOKIE); // HttpOnly + for (const value of Object.values(visible)) { + expect(value).not.toContain(stored!.accessToken); + expect(value).not.toContain(stored!.refreshToken ?? "no refresh token"); + expect(value).not.toContain("eyJ"); // no JWT anywhere + } + }); + + test("GET /bff/user describes the session and is never cached", async ({ + page, + context, + }) => { + await signInViaBff(page, MAIN.origin, "maintainer"); + const api = await raw(MAIN.origin, { + [SESSION_COOKIE]: await sessionId(context), + }); + const response = await api.get("/bff/user"); + expect(response.status()).toBe(200); + expect(response.headers()["cache-control"]).toContain("no-store"); + const user = (await response.json()) as Record; + expect(user).toMatchObject({ + name: "Mia Mason", + email: "mia.mason@blub.com", + roles: { api: ["maintainer"] }, + }); + expect(typeof user.sub).toBe("string"); + expect(Number(user.expiresAt)).toBeGreaterThan(Date.now() / 1000); + await api.dispose(); + }); + + test("the app is served with the BFF's security headers", async ({ + request, + }) => { + const response = await request.get("/"); + expect(response.status()).toBe(200); + const headers = response.headers(); + expect(headers["content-security-policy"]).toContain("default-src 'self'"); + expect(headers["x-content-type-options"]).toBe("nosniff"); + expect(headers["referrer-policy"]).toBeTruthy(); + expect(headers["cross-origin-opener-policy"]).toBeTruthy(); + expect(headers["access-control-allow-origin"]).toBeUndefined(); // no CORS + expect(headers["cache-control"]).toBe("no-cache"); // index.html revalidates + }); + + test("an unknown page falls back to the SPA, an unknown API path does not", async ({ + request, + }) => { + const page = await request.get("/some/deep/link", { + headers: { Accept: "text/html" }, + }); + expect(page.status()).toBe(200); + expect(await page.text()).toContain('
'); + const api = await request.get("/bff/nothing", { + headers: { Accept: "text/html" }, + }); + expect(api.status()).toBe(404); + expect(await api.json()).toEqual({ error: "not_found" }); + }); +}); + +test.describe("without a session", () => { + test("/bff/user and the proxy answer 401, a forged cookie too", async () => { + const anonymous = await raw(MAIN.origin, {}, { "X-CSRF": "1" }); + expect((await anonymous.get("/bff/user")).status()).toBe(401); + const proxied = await anonymous.get(HEROES); + expect(proxied.status()).toBe(401); + expect(await proxied.json()).toMatchObject({ error: "unauthorized" }); + + const forged = await raw( + MAIN.origin, + { [SESSION_COOKIE]: "A".repeat(43) }, + { "X-CSRF": "1" }, + ); + const rejected = await forged.get("/bff/user"); + expect(rejected.status()).toBe(401); + // A cookie the store does not know is cleared, not left in the browser. + expect(setCookies(rejected).join("\n")).toContain(`${SESSION_COOKIE}=;`); + await anonymous.dispose(); + await forged.dispose(); + }); + + test("a login for an unknown returnTo falls back to a same-origin path", async ({ + page, + }) => { + // A protocol-relative URL is not a same-origin path: the login must not + // redirect there, it lands on `/`. + await page.goto( + `${MAIN.origin}/bff/login?returnTo=${encodeURIComponent("//evil.example/pwn")}`, + { waitUntil: "domcontentloaded" }, + ); + await submitKeycloakForm(page, "detective"); + await page.waitForURL(`${MAIN.origin}/**`); + expect(new URL(page.url()).origin).toBe(MAIN.origin); + expect(new URL(page.url()).pathname).toBe("/"); + }); +}); + +test.describe("CSRF and Origin", () => { + test("a missing X-CSRF is rejected, with the session alive afterwards", async ({ + page, + context, + }) => { + await signInViaBff(page, MAIN.origin, "maintainer"); + const id = await sessionId(context); + await idle(page); + const api = await raw(MAIN.origin, { [SESSION_COOKIE]: id }); + + const logout = await api.post("/bff/logout"); // no X-CSRF + expect(logout.status()).toBe(403); + expect(await logout.json()).toEqual({ error: "csrf_rejected" }); + + // Every /api request needs it, GET included. + const noHeader = await api.get(HEROES); + expect(noHeader.status()).toBe(403); + expect(await noHeader.json()).toEqual({ error: "csrf_rejected" }); + const withHeader = await api.get(HEROES, { headers: { "X-CSRF": "1" } }); + expect(withHeader.status()).toBe(200); + + // A rejection never ends the session. + expect((await api.get("/bff/user")).status()).toBe(200); + expect(await storedSession(id)).not.toBeNull(); + await api.dispose(); + }); + + test("a foreign Origin or Sec-Fetch-Site is rejected, the app's own Origin passes", async ({ + page, + context, + }) => { + await signInViaBff(page, MAIN.origin, "maintainer"); + const id = await sessionId(context); + await idle(page); + const api = await raw( + MAIN.origin, + { [SESSION_COOKIE]: id }, + { "X-CSRF": "1" }, + ); + + const foreignOrigin = await api.post("/bff/logout", { + headers: { Origin: "https://evil.example" }, + }); + expect(foreignOrigin.status()).toBe(403); + expect(await foreignOrigin.json()).toEqual({ error: "origin_rejected" }); + + const crossSite = await api.post("/bff/logout", { + headers: { "Sec-Fetch-Site": "cross-site" }, + }); + expect(crossSite.status()).toBe(403); + expect(await crossSite.json()).toEqual({ error: "origin_rejected" }); + + // Origin is checked before X-CSRF, and PUBLIC_ORIGIN is exact (a lookalike fails). + const lookalike = await api.post("/bff/logout", { + headers: { Origin: `${MAIN.origin}.evil.example` }, + }); + expect(lookalike.status()).toBe(403); + + const same = await api.post("/bff/analytics", { + headers: { Origin: MAIN.origin, "Sec-Fetch-Site": "same-origin" }, + data: { name: "e2e.origin_ok" }, + }); + expect(same.status()).toBe(202); + + expect((await api.get("/bff/user")).status()).toBe(200); + expect( + logLines(MAIN.log).filter((l) => l.event === "origin_rejected").length, + ).toBeGreaterThanOrEqual(3); + await api.dispose(); + }); + + test("the BFF sends no CORS headers, not even to a preflight", async () => { + const api = await raw(MAIN.origin); + const preflight = await api.fetch("/bff/logout", { + method: "OPTIONS", + headers: { + Origin: "https://evil.example", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "x-csrf", + }, + }); + expect(preflight.headers()["access-control-allow-origin"]).toBeUndefined(); + expect(preflight.headers()["access-control-allow-headers"]).toBeUndefined(); + await api.dispose(); + }); +}); + +test.describe("logout", () => { + test("POST /bff/logout ends the session, clears the cookie and revokes the refresh token", async ({ + page, + context, + request, + }) => { + await signInViaBff(page, MAIN.origin, "editor"); + const id = await sessionId(context); + await idle(page); + const before = await storedSession(id); + const refreshToken = before!.refreshToken!; + const api = await raw( + MAIN.origin, + { [SESSION_COOKIE]: id }, + { "X-CSRF": "1" }, + ); + + const response = await api.post("/bff/logout"); + expect(response.status()).toBe(200); + const { logoutUrl } = (await response.json()) as { logoutUrl: string }; + expect(logoutUrl).toContain(`${REALM_URL}/protocol/openid-connect/logout`); + + // The cookie is cleared with the attributes it was set with. + const cleared = setCookies(response).find((c) => + c.startsWith(`${SESSION_COOKIE}=`), + ); + expect(cleared, "a clearing Set-Cookie").toBeDefined(); + expect(cleared).toMatch(/;\s*Path=\/(;|$)/i); + expect(cleared).toMatch(/;\s*(Expires=Thu, 01 Jan 1970|Max-Age=0)/i); + + // The session is gone from Valkey and the old cookie is useless. + expect(await storedSession(id)).toBeNull(); + const [exists] = await valkey(["EXISTS", `sess:${id}`]); + expect(exists).toBe(0); + expect((await api.get("/bff/user")).status()).toBe(401); + expect((await api.post("/bff/logout")).status()).toBe(401); + + // Best-effort RFC 7009 revocation: Keycloak refuses the old refresh token. + const refresh = await request.post( + `${REALM_URL}/protocol/openid-connect/token`, + { + form: { + grant_type: "refresh_token", + client_id: "bff", + client_secret: + process.env.E2E_BFF_CLIENT_SECRET ?? "dev-only-bff-client-secret", + refresh_token: refreshToken, + }, + }, + ); + expect(refresh.status()).toBe(400); + expect(await refresh.json()).toMatchObject({ error: "invalid_grant" }); + expect( + logLines(MAIN.log).some( + (l) => l.event === "logout" && l.security === true, + ), + ).toBeTruthy(); + await api.dispose(); + }); + + test("Sign out in the app ends the session and the Keycloak session too", async ({ + page, + context, + }) => { + await page.goto(MAIN.origin, { waitUntil: "domcontentloaded" }); + await page.getByRole("button", { name: "Sign in" }).click(); + await submitKeycloakForm(page, "maintainer"); + await expect(page.getByRole("button", { name: "Sign out" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Heroes" })).toBeVisible(); + const id = await sessionId(context); + + await page.getByRole("button", { name: "Sign out" }).click(); + // Keycloak's end-session endpoint returns to the app (a registered + // post-logout URI), which shows the signed-out state. + await page.waitForURL(`${MAIN.origin}/**`); + await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible(); + + expect(await sessionCookie(context)).toBeUndefined(); + expect(await storedSession(id)).toBeNull(); + + // Keycloak's SSO session is gone as well: logging in asks for credentials again. + await page.goto(`${MAIN.origin}/bff/login?returnTo=%2F`, { + waitUntil: "domcontentloaded", + }); + await expect(page.locator("#username")).toBeVisible(); + expect(page.url().startsWith(KEYCLOAK)).toBeTruthy(); + }); + + test("a session that is already gone also reads as signed out in the SPA", async ({ + page, + context, + }) => { + await page.goto(MAIN.origin, { waitUntil: "domcontentloaded" }); + await page.getByRole("button", { name: "Sign in" }).click(); + await submitKeycloakForm(page, "maintainer"); + await expect(page.getByRole("button", { name: "Sign out" })).toBeVisible(); + + // Lose the session behind the browser's back (a Valkey flush, an expiry). + await valkey(["DEL", `sess:${await sessionId(context)}`]); + await page.reload(); + await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible(); + }); +}); diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json new file mode 100644 index 0000000..46da750 --- /dev/null +++ b/tests/e2e/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["ES2023", "DOM"], + "types": ["node"], + "skipLibCheck": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "module": "esnext", + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["*.ts"] +} diff --git a/tests/e2e/wellknown.e2e.ts b/tests/e2e/wellknown.e2e.ts new file mode 100644 index 0000000..229cb80 --- /dev/null +++ b/tests/e2e/wellknown.e2e.ts @@ -0,0 +1,94 @@ +// The BFF's /.well-known/* endpoints and its public config, as a browser or a +// crawler sees them (no session, no X-CSRF: they are all GETs). The lane +// configures SECURITY_TXT_CONTACT and CHANGE_PASSWORD_URL (playwright.config.ts). +import { expect, test } from "@playwright/test"; +import { MAIN, REALM_URL } from "./lane"; + +test("security.txt (RFC 9116)", async ({ request }) => { + const response = await request.get("/.well-known/security.txt"); + expect(response.status()).toBe(200); + expect(response.headers()["content-type"]).toContain("text/plain"); + const body = await response.text(); + expect(body).toContain("Contact: mailto:security@example.invalid\n"); + expect(body).toContain( + `Canonical: ${MAIN.origin}/.well-known/security.txt\n`, + ); + const expires = /^Expires: (.+)$/m.exec(body)?.[1]; + expect(expires, "an Expires field").toBeDefined(); + expect(Date.parse(expires!)).toBeGreaterThan(Date.now()); + // RFC 9116 section 2.5.5: less than a year ahead. + expect(Date.parse(expires!)).toBeLessThan(Date.now() + 366 * 86_400_000); + + expect((await request.head("/.well-known/security.txt")).status()).toBe(200); +}); + +test("change-password redirects to the identity provider's account page", async ({ + request, +}) => { + const response = await request.get("/.well-known/change-password", { + maxRedirects: 0, + }); + expect(response.status()).toBe(302); + expect(response.headers().location).toBe(`${REALM_URL}/account/`); +}); + +test("gpc.json declares that Global Privacy Control is honoured", async ({ + request, +}) => { + const response = await request.get("/.well-known/gpc.json"); + expect(response.status()).toBe(200); + expect(response.headers()["content-type"]).toContain("application/json"); + expect(await response.json()).toEqual({ + gpc: true, + lastUpdate: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + }); +}); + +test("api-catalog (RFC 9727) lists the crud upstream", async ({ request }) => { + const response = await request.get("/.well-known/api-catalog"); + expect(response.status()).toBe(200); + expect(response.headers()["content-type"]).toContain( + "application/linkset+json", + ); + expect(response.headers().link).toContain('rel="api-catalog"'); + expect(await response.json()).toEqual({ + linkset: [ + { + anchor: `${MAIN.origin}/.well-known/api-catalog`, + item: [{ href: `${MAIN.origin}/api/crud/`, title: "crud" }], + "api-catalog": [ + { + href: `${MAIN.origin}/api/crud/.well-known/api-catalog`, + title: "crud", + }, + ], + }, + ], + }); +}); + +test("an unknown well-known path is a JSON 404, not the app", async ({ + request, +}) => { + const response = await request.get("/.well-known/nothing-here"); + expect(response.status()).toBe(404); + expect(response.headers()["content-type"]).toContain("application/json"); +}); + +test("/bff/config and the health endpoints", async ({ request }) => { + const config = await request.get("/bff/config"); + expect(config.status()).toBe(200); + expect(await config.json()).toEqual({ + analytics: { enabled: true, consent: "implied", respectGpc: true }, + upstreams: ["crud"], + }); + + expect((await request.get("/health/live")).status()).toBe(200); + // Ready needs the real Valkey and a reachable Keycloak discovery document. + const ready = await request.get("/health/ready"); + expect(ready.status()).toBe(200); + expect(await ready.json()).toMatchObject({ + status: "ok", + checks: { store: expect.anything(), idp: expect.anything() }, + }); +}); From fdf93438f97fdbe29ee07b013790c90d3c5cb2b7 Mon Sep 17 00:00:00 2001 From: crazyracer98 <8631139+crazyracer98@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:12:26 +0000 Subject: [PATCH 3/9] test(e2e): drive the BFF through a real Keycloak login, Valkey and API (BF5) Mark the two known failures with test.fail; wider expect timeout --- tests/e2e/README.md | 15 +++++++++++++++ tests/e2e/admin.e2e.ts | 9 +++++++++ tests/e2e/analytics.e2e.ts | 35 ++++++++++++++++++++++------------ tests/e2e/playwright.config.ts | 2 ++ 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 5ff017c..a555670 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -98,6 +98,21 @@ template-fastapi's devcontainer image and runs `uv sync`. The journeys create an archive one hero per run and never delete or truncate anything else; `down -v` discards the database anyway. +## Known failures (marked `test.fail`) + +Two tests are expected to fail until the cause is fixed; Playwright reports them +as failures the moment they start passing, so the marker cannot rot. + +- `admin.e2e.ts`: `@craftainer/bff` 0.1.0 sends a fixed CSP with `style-src +'self'`, which blocks Emotion's inline styles (the app renders unstyled), and + the grid's conditional save then fails with "Could not read this record's + version" (probably its forced `Cache-Control: no-store` on `/api/*`, which + crud-sdk's cache will not store). Both belong in `lib-bff-ts`. +- `analytics.e2e.ts` (`the trash view is reported as its own page`): this repo's + `src/analytics/pageViews.ts` reports the view only when the URL has a + `resource`, but the admin writes `?view=trash` alone, so every view is + reported as `/`. + ## Do - Keep it to journeys that need the real Keycloak, Valkey or backend; component diff --git a/tests/e2e/admin.e2e.ts b/tests/e2e/admin.e2e.ts index b460f48..fd173fd 100644 --- a/tests/e2e/admin.e2e.ts +++ b/tests/e2e/admin.e2e.ts @@ -34,6 +34,14 @@ test("the admin creates, edits, conflicts and archives a record", async ({ page, request, }) => { + // Known failure, two findings about @craftainer/bff 0.1.0 (reported, not + // worked around): its fixed CSP `style-src 'self'` blocks Emotion's inline + // styles (the app renders unstyled), and with the CSP out of the way the + // grid's conditional save still fails with "Could not read this record's + // version" (probably its forced `Cache-Control: no-store` on `/api/*`, which + // crud-sdk's cache will not store, so no ETag is kept). Remove this line + // once that is fixed; Playwright then flags the test as unexpectedly passing. + test.fail(true, "lib-bff-ts: CSP style-src and no-store on /api/*"); const name = `E2E ${Date.now()}`; await signIn(page, "maintainer"); await expect(page.getByRole("heading", { name: "Heroes" })).toBeVisible(); @@ -60,6 +68,7 @@ test("the admin creates, edits, conflicts and archives a record", async ({ const inlineName = `${name} inline`; const inlineSaved = page.waitForResponse( (r) => r.request().method() === "PATCH" && r.status() === 200, + { timeout: 20_000 }, ); await row.getByRole("gridcell").first().dblclick(); await page.keyboard.press("End"); diff --git a/tests/e2e/analytics.e2e.ts b/tests/e2e/analytics.e2e.ts index 38294f1..4eb18be 100644 --- a/tests/e2e/analytics.e2e.ts +++ b/tests/e2e/analytics.e2e.ts @@ -247,12 +247,9 @@ test.describe("page views from the SPA", () => { await expect( page.getByRole("tab", { name: "Trash", selected: true }), ).toBeVisible(); - await expect - .poll(() => - sent.some((s) => (s.body as { url?: string }).url?.endsWith("/trash")), - ) - .toBe(true); - await sleep(500); + // The URL now carries the view (`?view=trash`); it must not reach analytics. + await expect(page).toHaveURL(/[?&]view=trash/); + await sleep(1_000); // Every request was accepted, carried only a name and a bare path, and // (with the `log` backend) was recorded the way the browser sent it. @@ -263,18 +260,32 @@ test.describe("page views from the SPA", () => { expect(name).toBe("page.view"); expect(url).toMatch(/^\/[A-Za-z0-9_/-]*$/); // no query, fragment or id } - const urls = sent.map((s) => (s.body as { url: string }).url); - expect(urls.some((u) => u.endsWith("/trash"))).toBe(true); - expect(urls.some((u) => u.endsWith("/record"))).toBe(false); - const events = await until( () => loggedEvents(MAIN.log).filter((e) => e.name === "page.view"), - (list) => list.some((e) => e.url.endsWith("/trash")), + (list) => list.length > 0, ); - expect(events.some((e) => e.url.endsWith("/trash"))).toBe(true); + expect(events.length).toBeGreaterThan(0); expect(events.every((e) => !/[?#=\d]/.test(e.url))).toBe(true); }); + test("the trash view is reported as its own page", async ({ page }) => { + // Known failure in this repo's SPA, not in the BFF: `viewPath` only adds + // the view to the path when the URL also has a `resource`, but the admin + // with a single resource writes `?view=trash` alone, so every view is + // reported as `/`. Remove this line when `src/analytics/pageViews.ts` is + // fixed; Playwright then flags the test as unexpectedly passing. + test.fail(true, "src/analytics/pageViews.ts: viewPath needs `resource`"); + const urls: string[] = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/bff/analytics") { + urls.push((request.postDataJSON() as { url: string }).url); + } + }); + await signInViaBff(page, MAIN.origin, "maintainer", "/"); + await page.getByRole("tab", { name: "Trash" }).click(); + await expect.poll(() => urls.some((u) => u.endsWith("/trash"))).toBe(true); + }); + test("a browser that sends Global Privacy Control makes no request at all", async ({ browser, }) => { diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index cd8bb58..84ae25c 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -50,6 +50,8 @@ export default defineConfig({ workers: 1, forbidOnly: !!process.env.CI, retries: 0, + // A loaded host (Keycloak, Chromium and a Vite bundle on a few cores) is slow. + expect: { timeout: 15_000 }, reporter: [ ["list"], ["html", { outputFolder: "../../playwright-report", open: "never" }], From 3b02f4662c8dc312c2c3587fa125204c9ca49ce1 Mon Sep 17 00:00:00 2001 From: crazyracer98 <8631139+crazyracer98@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:51:13 +0000 Subject: [PATCH 4/9] fix(analytics): report the admin's default resource when the URL names none (BF5b) - viewPath takes a default resource; the admin publishes resources[0] via src/analytics/defaultResource.ts - usePageViews keeps the last path in a ref so the late default does not duplicate a view - remove the trash test.fail marker and its Known-failures entry; README e2e line points at lane.sh --- README.md | 2 +- src/analytics/defaultResource.ts | 28 ++++++++++++++++++++++++++++ src/analytics/pageViews.test.ts | 32 ++++++++++++++++++++++++++++++++ src/analytics/pageViews.ts | 25 +++++++++++++++++-------- src/pages/AdminPage.tsx | 8 ++++++++ tests/e2e/README.md | 8 ++------ tests/e2e/analytics.e2e.ts | 6 ------ 7 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 src/analytics/defaultResource.ts diff --git a/README.md b/README.md index 798a0d4..c5376ba 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ pnpm build # production bundle -> dist/ pnpm storybook # component workshop, http://localhost:6006 tests/visual/run.sh # visual regression + axe over the stories (Docker) tests/visual/run.sh update # rewrite the screenshot baselines -tests/e2e/run.sh # admin journey against a running template-fastapi (Docker) +tests/e2e/lane.sh # real-stack e2e: Keycloak, Valkey, BFF and API (Docker) ``` ## Connecting to the backend diff --git a/src/analytics/defaultResource.ts b/src/analytics/defaultResource.ts new file mode 100644 index 0000000..e74b18e --- /dev/null +++ b/src/analytics/defaultResource.ts @@ -0,0 +1,28 @@ +// The admin resource shown when the URL names none (the first one in the +// caller's manifest). Only the admin knows it, and it sits below the page-view +// hook in the tree, so the admin publishes it here and `usePageViews` reads it: +// without it, an admin with one resource (which writes `?view=trash` alone) +// would report every view as `/`. +import { useSyncExternalStore } from "react"; + +let current: string | undefined; +const listeners = new Set<() => void>(); + +/** Called by the admin; `undefined` clears it (manifest not loaded, unmounted). */ +export function setDefaultResource(name: string | undefined): void { + if (name === current) return; + current = name; + for (const listener of [...listeners]) listener(); +} + +const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; +const read = () => current; + +export function useDefaultResource(): string | undefined { + return useSyncExternalStore(subscribe, read, () => undefined); +} diff --git a/src/analytics/pageViews.test.ts b/src/analytics/pageViews.test.ts index 3ccd00c..cae44d6 100644 --- a/src/analytics/pageViews.test.ts +++ b/src/analytics/pageViews.test.ts @@ -2,6 +2,7 @@ import { renderHook } from "@testing-library/react"; import { act } from "react"; import { memoryUrlAdapter } from "@craftainer/crud-views"; import { describe, expect, it, vi } from "vitest"; +import { setDefaultResource } from "./defaultResource"; import { PAGE_VIEW_EVENT, usePageViews, viewPath } from "./pageViews"; describe("viewPath", () => { @@ -17,6 +18,17 @@ describe("viewPath", () => { expect(viewPath("/", "")).toBe("/"); }); + it("falls back to the admin's default resource when the URL names none", () => { + expect(viewPath("/", "view=trash", "heroes")).toBe("/heroes/trash"); + expect(viewPath("/", "", "heroes")).toBe("/heroes"); + expect(viewPath("/", "id=42", "heroes")).toBe("/heroes/record"); + expect(viewPath("/", "resource=villains&view=trash", "heroes")).toBe( + "/villains/trash", + ); + expect(viewPath("/", "view=trash")).toBe("/"); + expect(viewPath("/", "view=trash", "../x")).toBe("/"); + }); + it("ignores values that are not plain segments", () => { expect(viewPath("/admin", "resource=../x")).toBe("/admin"); expect(viewPath("/admin", "resource=heroes&view=a%2Fb")).toBe( @@ -54,6 +66,26 @@ describe("usePageViews", () => { expect(send).toHaveBeenCalledTimes(2); }); + it("reports the default resource once the admin publishes it, without a duplicate", () => { + const adapter = memoryUrlAdapter("view=trash"); + const send = vi.fn(); + renderHook(() => usePageViews(adapter, () => "/", send)); + expect(send).toHaveBeenLastCalledWith(PAGE_VIEW_EVENT, undefined, "/"); + + act(() => setDefaultResource("heroes")); + expect(send).toHaveBeenLastCalledWith( + PAGE_VIEW_EVENT, + undefined, + "/heroes/trash", + ); + expect(send).toHaveBeenCalledTimes(2); + + act(() => setDefaultResource("heroes")); // unchanged + expect(send).toHaveBeenCalledTimes(2); + act(() => setDefaultResource(undefined)); + expect(send).toHaveBeenLastCalledWith(PAGE_VIEW_EVENT, undefined, "/"); + }); + it("does not re-send the same view when the host re-renders", () => { const send = vi.fn(); const pathname = () => "/admin"; diff --git a/src/analytics/pageViews.ts b/src/analytics/pageViews.ts index 37f728e..2d6bf36 100644 --- a/src/analytics/pageViews.ts +++ b/src/analytics/pageViews.ts @@ -3,8 +3,9 @@ // "page" is the path plus that state's resource and view. Record ids, filters // and paging (which can carry personal data) never leave the browser: the // reported `url` is a bare path such as `/admin/heroes/trash`. -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { defaultUrlAdapter, type UrlAdapter } from "@craftainer/crud-views"; +import { useDefaultResource } from "./defaultResource"; import { track } from "./track"; export const PAGE_VIEW_EVENT = "page.view"; @@ -12,11 +13,16 @@ export const PAGE_VIEW_EVENT = "page.view"; const SEGMENT = /^[A-Za-z0-9_-]{1,64}$/; /** The reported path for a location: `[/[/|/record]]`. */ -export function viewPath(pathname: string, search: string): string { +export function viewPath( + pathname: string, + search: string, + defaultResource?: string, +): string { const params = new URLSearchParams(search); const base = pathname.replace(/\/+$/, ""); const parts = [base]; - const resource = params.get("resource"); + // The admin leaves `resource` out when it shows its default (first) one. + const resource = params.get("resource") ?? defaultResource; if (resource && SEGMENT.test(resource)) { parts.push(resource); const view = params.get("view"); @@ -40,15 +46,18 @@ export function usePageViews( // Created once: a fresh default adapter per render would re-run the effect // and re-send the same view. const [adapter] = useState(() => adapterArg ?? defaultUrlAdapter()); + const defaultResource = useDefaultResource(); + // Survives the effect re-running when the default resource arrives, so the + // same path is not sent twice. + const last = useRef(null); useEffect(() => { - let last: string | null = null; const report = () => { - const path = viewPath(pathname(), adapter.read()); - if (path === last) return; - last = path; + const path = viewPath(pathname(), adapter.read(), defaultResource); + if (path === last.current) return; + last.current = path; send(PAGE_VIEW_EVENT, undefined, path); }; report(); return adapter.subscribe(report); - }, [adapter, pathname, send]); + }, [adapter, pathname, send, defaultResource]); } diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx index b13f966..f8dac7a 100644 --- a/src/pages/AdminPage.tsx +++ b/src/pages/AdminPage.tsx @@ -26,6 +26,7 @@ import { type ReadyResource, type UrlAdapter, } from "@craftainer/crud-views"; +import { setDefaultResource } from "../analytics/defaultResource"; import { useAuth } from "../auth/authContext"; import { CrudApiProvider } from "../api/CrudApi"; import { useCrudApi } from "../api/crudApiContext"; @@ -115,6 +116,13 @@ function Admin({ urlAdapter }: AdminProps) { const resources = useMemo(() => latestResources(manifest), [manifest]); const { params, update } = useUrlParams(urlAdapter); + // Page views name the resource the URL leaves out (analytics/defaultResource). + const defaultName = resources[0]?.name; + useEffect(() => { + setDefaultResource(defaultName); + return () => setDefaultResource(undefined); + }, [defaultName]); + if (resources.length === 0) { return ( diff --git a/tests/e2e/README.md b/tests/e2e/README.md index a555670..b95b087 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -100,18 +100,14 @@ discards the database anyway. ## Known failures (marked `test.fail`) -Two tests are expected to fail until the cause is fixed; Playwright reports them -as failures the moment they start passing, so the marker cannot rot. +One test is expected to fail until the cause is fixed; Playwright reports it +as a failure the moment it starts passing, so the marker cannot rot. - `admin.e2e.ts`: `@craftainer/bff` 0.1.0 sends a fixed CSP with `style-src 'self'`, which blocks Emotion's inline styles (the app renders unstyled), and the grid's conditional save then fails with "Could not read this record's version" (probably its forced `Cache-Control: no-store` on `/api/*`, which crud-sdk's cache will not store). Both belong in `lib-bff-ts`. -- `analytics.e2e.ts` (`the trash view is reported as its own page`): this repo's - `src/analytics/pageViews.ts` reports the view only when the URL has a - `resource`, but the admin writes `?view=trash` alone, so every view is - reported as `/`. ## Do diff --git a/tests/e2e/analytics.e2e.ts b/tests/e2e/analytics.e2e.ts index 4eb18be..27841f6 100644 --- a/tests/e2e/analytics.e2e.ts +++ b/tests/e2e/analytics.e2e.ts @@ -269,12 +269,6 @@ test.describe("page views from the SPA", () => { }); test("the trash view is reported as its own page", async ({ page }) => { - // Known failure in this repo's SPA, not in the BFF: `viewPath` only adds - // the view to the path when the URL also has a `resource`, but the admin - // with a single resource writes `?view=trash` alone, so every view is - // reported as `/`. Remove this line when `src/analytics/pageViews.ts` is - // fixed; Playwright then flags the test as unexpectedly passing. - test.fail(true, "src/analytics/pageViews.ts: viewPath needs `resource`"); const urls: string[] = []; page.on("request", (request) => { if (new URL(request.url()).pathname === "/bff/analytics") { From 8d52ee8de29d7f574bac4603246ed7f12b67d2c0 Mon Sep 17 00:00:00 2001 From: crazyracer98 <8631139+crazyracer98@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:15:18 +0000 Subject: [PATCH 5/9] test(admin): the default resource is published once, with no loop or duplicate page view (BF5b) --- src/pages/AdminPage.test.tsx | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/pages/AdminPage.test.tsx b/src/pages/AdminPage.test.tsx index d2152e0..de01108 100644 --- a/src/pages/AdminPage.test.tsx +++ b/src/pages/AdminPage.test.tsx @@ -2,6 +2,8 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { memoryUrlAdapter } from "@craftainer/crud-views"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useDefaultResource } from "../analytics/defaultResource"; +import { PAGE_VIEW_EVENT, usePageViews } from "../analytics/pageViews"; import { AdminPage } from "./AdminPage"; import { installAdminStub } from "./adminStub"; @@ -37,6 +39,39 @@ describe("AdminPage", () => { expect(screen.getByRole("tab", { name: "Trash" })).toBeInTheDocument(); }); + it("publishes its default resource once: no render loop, no duplicate page view", async () => { + installAdminStub(); + const adapter = memoryUrlAdapter("view=trash"); + const send = vi.fn(); + const published: (string | undefined)[] = []; + function Probe() { + usePageViews(adapter, () => "/", send); + published.push(useDefaultResource()); + return null; + } + + render( + <> + + + , + ); + await screen.findByRole("heading", { name: "Heroes" }); + await new Promise((resolve) => setTimeout(resolve, 200)); // time to loop + + // The store changed twice at most (unset, then "heroes"); every other + // render of the probe saw the same value. + const changes = published.filter( + (v, i) => i === 0 || v !== published[i - 1], + ); + expect(changes).toEqual([undefined, "heroes"]); + expect(published.length).toBeLessThan(10); + expect(send.mock.calls.map((c) => [c[0], c[2]])).toEqual([ + [PAGE_VIEW_EVENT, "/"], + [PAGE_VIEW_EVENT, "/heroes/trash"], + ]); + }); + it("says so when the caller can see no resource", async () => { installAdminStub({ manifest: "none" }); From a7182a7e5f0c308707368dcad0081569520f03d8 Mon Sep 17 00:00:00 2001 From: crazyracer98 <8631139+crazyracer98@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:28:44 +0000 Subject: [PATCH 6/9] feat(csp): Vite html.cspNonce and an Emotion cache with the BFF's per-request nonce (BF5c phase A) - vite.config.ts: html.cspNonce __CSP_NONCE__ - src/theme/emotionCache.ts: nonce from meta[property=csp-nonce].nonce, cache key mui, prepend; ThemeProvider wraps in CacheProvider - @emotion/cache 11.14.0 as a direct dependency (lockfile version) - ADR 0007 amendment; e2e Known failures note --- docs/adrs/0007-adopt-craftainer-bff.md | 24 ++++++++++++ package.json | 1 + pnpm-lock.yaml | 3 ++ src/theme/ThemeProvider.tsx | 17 ++++++--- src/theme/emotionCache.test.ts | 53 ++++++++++++++++++++++++++ src/theme/emotionCache.ts | 25 ++++++++++++ tests/e2e/README.md | 4 ++ vite.config.ts | 5 +++ 8 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 src/theme/emotionCache.test.ts create mode 100644 src/theme/emotionCache.ts diff --git a/docs/adrs/0007-adopt-craftainer-bff.md b/docs/adrs/0007-adopt-craftainer-bff.md index b9481da..e8d16c1 100644 --- a/docs/adrs/0007-adopt-craftainer-bff.md +++ b/docs/adrs/0007-adopt-craftainer-bff.md @@ -78,3 +78,27 @@ the SPA holds no tokens: - Moving between hosts or origins changes `PUBLIC_ORIGIN` and the realm's redirect URIs together. Image runs on a developer machine use host port 8081 (`8081:8080`), because 8080 is Keycloak. + +## Amendment: the CSP style nonce (BF5c) + +The library's fixed `style-src 'self'` blocked Emotion's runtime `