From d138292addaede2dadf33cf2b3b62e6942222105 Mon Sep 17 00:00:00 2001 From: angela-helios Date: Thu, 30 Jul 2026 01:06:56 -0400 Subject: [PATCH] test(ui): exercise the conformance self-fetch under every auth mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two e2e legs join the browser suite (#320): auth enabled with a provisioned outbound service token — the conformance pages render real registry data through the authenticated self-call, while anonymous API calls stay 401 — and auth enabled without one, where the pages degrade to their warning state and the failed fetch is retried, not cached. boot.mjs grows the whole IdP surface the server needs: a throwaway RSA key served as a JWKS from a local HTTP server, and locally-minted RS256 tokens with the two conformance read scopes. The service token carries no tenant claim on purpose — the claim is authoritative when present, and the self-fetch scopes each call with X-Tenant-ID. The compartments page previously answered an empty definitions fetch with a 404; it now renders the shell with a warning like the SearchParameter page, in the three locales. The book documents the supported modes (off / static bearer / IdP-issued) and the planned JwtAssertionOutboundAuthProvider follow-up. Closes #320 --- book/src/SUMMARY.md | 1 + book/src/components/web-ui-self-calls.md | 44 +++++++++++++ crates/ui/e2e/boot.mjs | 63 ++++++++++++++++++- crates/ui/e2e/playwright.config.ts | 60 +++++++++++++++--- crates/ui/e2e/tests/auth/conformance.spec.ts | 37 +++++++++++ crates/ui/e2e/tests/auth/degraded.spec.ts | 28 +++++++++ crates/ui/src/lib.rs | 19 +++++- .../pages/compartments-degraded.html | 15 +++++ crates/ui/tests/router_http.rs | 30 +++++++++ locales/de/main.ftl | 1 + locales/en/main.ftl | 1 + locales/es/main.ftl | 1 + 12 files changed, 287 insertions(+), 13 deletions(-) create mode 100644 book/src/components/web-ui-self-calls.md create mode 100644 crates/ui/e2e/tests/auth/conformance.spec.ts create mode 100644 crates/ui/e2e/tests/auth/degraded.spec.ts create mode 100644 crates/ui/templates/pages/compartments-degraded.html diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 6ab832868..8431d7b07 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -7,6 +7,7 @@ - [FHIRPath Expressions](ch05-fhirpath.md) - [SQL-on-FHIR](ch06-sql-on-fhir.md) - [Natural-Language Search](components/natural-language-search.md) +- [Web UI Self-Calls and Authentication](components/web-ui-self-calls.md) - [Architecture Overview](ch07-architecture.md) - [Multi-Version FHIR Support](ch08-versions.md) - [Python Bindings (pysof)](ch09-pysof.md) diff --git a/book/src/components/web-ui-self-calls.md b/book/src/components/web-ui-self-calls.md new file mode 100644 index 000000000..813d62b3d --- /dev/null +++ b/book/src/components/web-ui-self-calls.md @@ -0,0 +1,44 @@ +# Web UI Self-Calls and Authentication + +The HFS web UI reads its conformance data — the SearchParameter registry and +the CompartmentDefinition set — from the server's **own FHIR API** over HTTP +(`GET /SearchParameter`, `GET /CompartmentDefinition` on the loopback +address). Primary storage is the source of truth, and the UI shows exactly +what the server serves; there is no side channel and **no auth carve-out**: +the self-call is authenticated exactly like any other client when +authentication is enabled. + +## Supported modes + +| Mode | Configuration | Self-call behavior | +|------|---------------|--------------------| +| **Off** (default) | `HFS_AUTH_ENABLED` unset or `false` | The self-call carries no credentials; everything works out of the box. | +| **Static bearer** | `HFS_AUTH_ENABLED=true` + `HFS_OUTBOUND_BEARER_TOKEN=` | The self-call attaches the provisioned token. The token must be valid against the server's own validation config (`HFS_AUTH_JWKS_URL`, `HFS_AUTH_ISSUER`, `HFS_AUTH_AUDIENCE`) and carry the read scopes `system/SearchParameter.rs system/CompartmentDefinition.rs`. | +| **IdP-issued** | As above, with the token minted by your identity provider (e.g. a Keycloak service-account client) | Same as static bearer — the operator obtains a token via `client_credentials` and provisions it. Note the token's lifetime: when it expires the self-call starts failing and the pages degrade until a fresh token is provisioned. | + +Leave the token's **tenant claim unset** for service tokens: the tenant claim +is authoritative when present, and the self-call scopes each request with +`X-Tenant-ID` so every tenant's conformance view stays reachable. A token +pinned to one tenant pins every conformance page to that tenant. + +## Degraded state + +With authentication enabled and **no valid outbound token**, the self-call is +rejected and the conformance pages **degrade to a warning** — they render the +page shell with a notice instead of data (never a 404 or a crash). The failed +fetch is **not cached**: the next request re-attempts it, so provisioning a +token (or fixing an expired one) heals the pages without a restart. + +Both paths are exercised in CI by the browser suite's `auth` and +`auth-degraded` legs (`crates/ui/e2e`, #320), which boot the server against a +throwaway JWKS and mint the service token locally. + +## Planned: self-minted service tokens + +The static token puts key rotation on the operator. The planned follow-up is +a `JwtAssertionOutboundAuthProvider` (see `crates/auth/src/outbound.rs` and +the note in `crates/hfs/src/main.rs`): SMART Backend Services +`client_credentials` with `private_key_jwt`, configured from `HFS_UI_*` +client credentials, minting short-lived, auto-refreshed tokens with exactly +the two conformance read scopes. Until then, provision +`HFS_OUTBOUND_BEARER_TOKEN` with a long-lived token from your IdP. diff --git a/crates/ui/e2e/boot.mjs b/crates/ui/e2e/boot.mjs index 4ee211763..a6dfa0e68 100644 --- a/crates/ui/e2e/boot.mjs +++ b/crates/ui/e2e/boot.mjs @@ -3,7 +3,9 @@ // --features ui`), points it at a throwaway SQLite DB, and serves /ui. Playwright's // webServer waits for the port, then tears this down. import { spawn } from "node:child_process"; +import { generateKeyPairSync, sign } from "node:crypto"; import { existsSync, rmSync, statSync } from "node:fs"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; @@ -32,17 +34,73 @@ if (!bin) { // seeding write ~1.4k resources one insert (and one fsync) at a time, which // on the container's overlayfs turns tenant creation into a minutes-long // request. /dev/shm makes those fsyncs free without changing what is tested. +const port = process.env.HFS_E2E_PORT || "8080"; const dbDir = existsSync("/dev/shm") ? "/dev/shm" : tmpdir(); -const db = join(dbDir, ".hfs-e2e.db"); +// Per-port DB: the auth legs (#320) run their own servers next to this one. +const db = join(dbDir, `.hfs-e2e-${port}.db`); for (const suffix of ["", "-wal", "-shm"]) { try { rmSync(db + suffix, { force: true }); } catch {} } +/* + * Auth legs (#320). HFS_E2E_AUTH turns bearer authentication on, backed by a + * throwaway RSA key served as a JWKS from a local HTTP server — the whole IdP + * surface the server needs for validation, with no external dependency: + * + * - "token": HFS_OUTBOUND_BEARER_TOKEN carries a minted service token with + * the conformance read scopes, so the UI's self-fetch works. + * - "degraded": auth is on but no outbound token is provisioned — the + * self-fetch is rejected and the pages must degrade, not 404. + * + * The token carries no tenant claim on purpose: the claim is authoritative + * when present, and the self-fetch scopes each call with X-Tenant-ID instead. + */ +const AUTH_MODE = process.env.HFS_E2E_AUTH || ""; +const ISSUER = "https://e2e.hfs.invalid/issuer"; +const AUDIENCE = "hfs-e2e"; +let authEnv = {}; +if (AUTH_MODE) { + const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const kid = "hfs-e2e-key"; + const jwks = JSON.stringify({ + keys: [{ ...publicKey.export({ format: "jwk" }), kid, alg: "RS256", use: "sig" }], + }); + const jwksPort = Number(port) + 1; + createServer((req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(jwks); + }).listen(jwksPort, "127.0.0.1"); + + const b64url = (buf) => Buffer.from(buf).toString("base64url"); + const mint = (claims) => { + const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT", kid })); + const now = Math.floor(Date.now() / 1000); + const payload = b64url( + JSON.stringify({ iss: ISSUER, aud: AUDIENCE, iat: now, exp: now + 86400, ...claims }), + ); + const signature = b64url(sign("sha256", Buffer.from(`${header}.${payload}`), privateKey)); + return `${header}.${payload}.${signature}`; + }; + + authEnv = { + HFS_AUTH_ENABLED: "true", + HFS_AUTH_JWKS_URL: `http://127.0.0.1:${jwksPort}/jwks.json`, + HFS_AUTH_ISSUER: ISSUER, + HFS_AUTH_AUDIENCE: AUDIENCE, + }; + if (AUTH_MODE === "token") { + authEnv.HFS_OUTBOUND_BEARER_TOKEN = mint({ + sub: "hfs-ui-self", + scope: "system/SearchParameter.rs system/CompartmentDefinition.rs", + }); + } +} + const child = spawn(bin, [], { stdio: "inherit", env: { ...process.env, - HFS_SERVER_PORT: process.env.HFS_E2E_PORT || "8080", + HFS_SERVER_PORT: port, HFS_STORAGE_BACKEND: "sqlite", HFS_DATABASE_URL: db, // Load the vendored SearchParameter specs (so search works like production), @@ -52,6 +110,7 @@ const child = spawn(bin, [], { // Natural-language search advertises itself as configured so the search // area renders its working pane. The key is never used by the tests. HFS_NL_SEARCH_API_KEY: "e2e-placeholder-not-a-real-key", + ...authEnv, }, }); diff --git a/crates/ui/e2e/playwright.config.ts b/crates/ui/e2e/playwright.config.ts index f930ccbfe..c27b12f17 100644 --- a/crates/ui/e2e/playwright.config.ts +++ b/crates/ui/e2e/playwright.config.ts @@ -27,7 +27,7 @@ export default defineConfig({ { // The JS-enabled ring: theme behavior, axe-core a11y, no-CDN invariants. name: "chromium", - testIgnore: "**/nojs/**", + testIgnore: ["**/nojs/**", "**/auth/**"], use: { ...devices["Desktop Chrome"] }, }, { @@ -36,6 +36,24 @@ export default defineConfig({ testMatch: "**/nojs/**/*.spec.ts", use: { ...devices["Desktop Chrome"], javaScriptEnabled: false }, }, + // The auth legs (#320) drive their own servers (see webServer below), so + // they only exist when this run boots its servers itself. + ...(externalBase + ? [] + : [ + { + // Auth enabled + outbound service token: conformance self-fetch works. + name: "auth", + testMatch: "**/auth/conformance.spec.ts", + use: { ...devices["Desktop Chrome"], baseURL: `http://127.0.0.1:${PORT + 10}` }, + }, + { + // Auth enabled, no outbound token: the pages degrade, never 404. + name: "auth-degraded", + testMatch: "**/auth/degraded.spec.ts", + use: { ...devices["Desktop Chrome"], baseURL: `http://127.0.0.1:${PORT + 20}` }, + }, + ]), ], // Boot our own hfs only when no external server was handed to us. The backend // matrix launches hfs on the host and sets HFS_E2E_BASE_URL, so there we skip @@ -43,14 +61,36 @@ export default defineConfig({ ...(externalBase ? {} : { - webServer: { - command: "node boot.mjs", - // Readiness probe: the FHIR root does not 200, but /ui does. - url: `${baseURL}/ui`, - reuseExistingServer: !process.env.CI, - timeout: 120_000, - stdout: "pipe", - stderr: "pipe", - }, + webServer: [ + { + command: "node boot.mjs", + // Readiness probe: the FHIR root does not 200, but /ui does. + url: `${baseURL}/ui`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + stdout: "pipe", + stderr: "pipe", + }, + // The auth legs (#320): same binary, bearer auth enabled against a + // throwaway JWKS boot.mjs serves on the port next to the server's. + { + command: "node boot.mjs", + url: `http://127.0.0.1:${PORT + 10}/ui`, + env: { HFS_E2E_PORT: String(PORT + 10), HFS_E2E_AUTH: "token" }, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + stdout: "pipe", + stderr: "pipe", + }, + { + command: "node boot.mjs", + url: `http://127.0.0.1:${PORT + 20}/ui`, + env: { HFS_E2E_PORT: String(PORT + 20), HFS_E2E_AUTH: "degraded" }, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + stdout: "pipe", + stderr: "pipe", + }, + ], }), }); diff --git a/crates/ui/e2e/tests/auth/conformance.spec.ts b/crates/ui/e2e/tests/auth/conformance.spec.ts new file mode 100644 index 000000000..5d7e01231 --- /dev/null +++ b/crates/ui/e2e/tests/auth/conformance.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from "../../pages/fixtures"; + +// #320, leg 1: auth enabled AND an outbound service token provisioned +// (HFS_OUTBOUND_BEARER_TOKEN, minted by boot.mjs against the throwaway JWKS). +// The UI's self-fetch of /SearchParameter and /CompartmentDefinition carries +// that token, so the conformance pages must render real data — no carve-outs +// for the loopback call. + +test("search parameters render real registry data under auth", async ({ + page, + searchParameters, +}) => { + await searchParameters.goto(); + // No degraded-fetch warning... + await expect(page.locator(".notice--warn")).toHaveCount(0); + // ...and the full spec registry came through the authenticated self-call. + await expect(searchParameters.rows.first()).toBeVisible(); + const total = await page.locator("#sp-rail-list .filter-rail__item .count").first().innerText(); + expect(Number(total.replace(/\D/g, ""))).toBeGreaterThan(500); +}); + +test("compartments render the five spec definitions under auth", async ({ page }) => { + await page.goto("/ui/compartments", { waitUntil: "networkidle" }); + await expect(page.locator(".notice--warn")).toHaveCount(0); + for (const code of ["Patient", "Encounter", "Practitioner", "RelatedPerson", "Device"]) { + await expect(page.locator(".filter-rail__item", { hasText: code })).toBeVisible(); + } +}); + +test("a direct FHIR call without a token is still rejected", async ({ request }) => { + // The pages work because the self-call carries the service token — not + // because auth grew a loophole. An anonymous API call stays a 401. + const bare = await request.get("/SearchParameter?_count=1", { + headers: { Accept: "application/fhir+json" }, + }); + expect(bare.status()).toBe(401); +}); diff --git a/crates/ui/e2e/tests/auth/degraded.spec.ts b/crates/ui/e2e/tests/auth/degraded.spec.ts new file mode 100644 index 000000000..bd066ab72 --- /dev/null +++ b/crates/ui/e2e/tests/auth/degraded.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from "../../pages/fixtures"; + +// #320, leg 2: auth enabled but NO outbound token provisioned. The self-fetch +// is rejected (401), and the conformance pages must degrade to their warning +// state — no crash, no empty 404 — and re-attempt the fetch on the next +// request instead of caching the failure. + +test("search parameters degrade to the warning state", async ({ page, searchParameters }) => { + await searchParameters.goto(); + await expect(page.locator(".notice--warn")).toBeVisible(); + await expect(searchParameters.rows).toHaveCount(0); +}); + +test("compartments degrade to a warning page, not a 404", async ({ page }) => { + const response = await page.goto("/ui/compartments", { waitUntil: "networkidle" }); + expect(response?.status()).toBe(200); + await expect(page.locator(".notice--warn")).toBeVisible(); + await expect(page.locator("h1.page-head__title")).toBeVisible(); +}); + +test("the degraded fetch is retried, not cached", async ({ page, searchParameters }) => { + // Two consecutive loads both warn — and both actually hit the API again: + // the failed snapshot is served degraded for its request only. + await searchParameters.goto(); + await expect(page.locator(".notice--warn")).toBeVisible(); + await searchParameters.goto(); + await expect(page.locator(".notice--warn")).toBeVisible(); +}); diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index c79c672b0..ed5f9b164 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -459,6 +459,16 @@ struct CompartmentsPage { view: compartments::CmpView, } +/// The compartments page when no definitions could be fetched (#320): the +/// shell with a warning, in place of the data-bearing view. +#[derive(Template)] +#[template(path = "pages/compartments-degraded.html")] +struct CompartmentsDegradedPage { + status: Status, + i18n: I18n, + active_page: &'static str, +} + #[derive(Template)] #[template(path = "partials/status.html")] struct StatusPartial { @@ -1052,7 +1062,14 @@ async fn compartments_page( active_page: "compartments", view, }), - None => StatusCode::NOT_FOUND.into_response(), + // No definitions means the self-fetch degraded (an outage, or auth + // without an outbound token, #320) — a warning, not a 404. The failed + // fetch is not cached, so the next request re-attempts it. + None => render(CompartmentsDegradedPage { + status: current_status(state.version, rv.0, &rt), + i18n: I18n::new(locale), + active_page: "compartments", + }), } } diff --git a/crates/ui/templates/pages/compartments-degraded.html b/crates/ui/templates/pages/compartments-degraded.html new file mode 100644 index 000000000..2ecfb8ee9 --- /dev/null +++ b/crates/ui/templates/pages/compartments-degraded.html @@ -0,0 +1,15 @@ +{% extends "layouts/base.html" %} + +{% block title %}{{ i18n.t("cmp-heading") }} — {{ i18n.t("app-title") }}{% endblock %} + +{% block content %} +
+

{{ i18n.t("cmp-heading") }}

+

{{ i18n.t("cmp-lede") }}

+
+ + +

{{ i18n.t("cmp-degraded") }}

+{% endblock %} diff --git a/crates/ui/tests/router_http.rs b/crates/ui/tests/router_http.rs index f95d214b1..af36d35de 100644 --- a/crates/ui/tests/router_http.rs +++ b/crates/ui/tests/router_http.rs @@ -204,6 +204,36 @@ async fn search_parameters_selection_renders_the_detail_panel() { assert!(html.contains("Patient.name")); } +/// #320: when the conformance self-fetch yields nothing (an outage, or auth +/// without an outbound service token), the compartments page degrades to a +/// warning — it must not 404. +#[tokio::test] +async fn compartments_degrade_to_a_warning_when_the_fetch_is_empty() { + let response = helios_ui::mount_with_conformance_source( + Router::new(), + "9.9.9", + Some(std::path::PathBuf::from("../../data")), + nl(true, true), + None, + None, + "default".to_string(), + std::sync::Arc::new(helios_ui::StaticConformanceSource::empty()), + helios_fhir::FhirVersion::R4, + ) + .oneshot( + Request::get("/ui/compartments") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let html = body_text(response).await; + assert!(html.contains("notice--warn")); + assert!(html.contains("")); +} + #[tokio::test] async fn compartments_page_defaults_to_patient() { let response = app() diff --git a/locales/de/main.ftl b/locales/de/main.ftl index 0b9d4eef7..8f67dcf41 100644 --- a/locales/de/main.ftl +++ b/locales/de/main.ftl @@ -256,6 +256,7 @@ cmp-heading = Compartments cmp-lede = Die Compartment-Definitionen, mit denen dieser Server /{"{"}compartment{"}"}/{"{"}id{"}"}/{"{"}type{"}"}-Anfragen routet, und ein Tester, der beantwortet: Ist dieser Typ in diesem Compartment, über welche Parameter, und welche Suche führt der Server aus? cmp-rail-label = Compartment-Definitionen cmp-rail-heading = Compartments +cmp-degraded = Die Compartment-Definitionen konnten gerade nicht von diesem Server geladen werden — der Selbstaufruf an /CompartmentDefinition schlug fehl (bei aktivierter Authentifizierung fehlt meist das ausgehende Service-Token oder es ist ungültig). Die Seite versucht es bei der nächsten Anfrage erneut. cmp-rail-note = Die Basisdefinitionen werden mit dem Server ausgeliefert (aus der FHIR-Spezifikation generiert). Sie zu bearbeiten setzt eine tenant-spezifische Override-Schicht voraus — offene Frage im Issue. cmp-tabs-label = Compartment-Bereiche cmp-tab-definition = Definition diff --git a/locales/en/main.ftl b/locales/en/main.ftl index 0d19c8446..00c4449d2 100644 --- a/locales/en/main.ftl +++ b/locales/en/main.ftl @@ -260,6 +260,7 @@ cmp-heading = Compartments cmp-lede = The compartment definitions this server routes /{"{"}compartment{"}"}/{"{"}id{"}"}/{"{"}type{"}"} requests with, and a tester that answers: is this type in this compartment, via which parameters, and what search does the server run? cmp-rail-label = Compartment definitions cmp-rail-heading = Compartments +cmp-degraded = Compartment definitions could not be loaded from this server right now — the self-call to /CompartmentDefinition failed (with authentication enabled this usually means the outbound service token is missing or invalid). The page retries on the next request. cmp-rail-note = Base definitions ship with the server (codegen'd from the FHIR spec). Editing them implies a tenant-scoped override layer — open question on the issue. cmp-tabs-label = Compartment sections cmp-tab-definition = Definition diff --git a/locales/es/main.ftl b/locales/es/main.ftl index 057aafa6f..cb8673347 100644 --- a/locales/es/main.ftl +++ b/locales/es/main.ftl @@ -256,6 +256,7 @@ cmp-heading = Compartimentos cmp-lede = Las definiciones de compartment con las que este servidor enruta las peticiones /{"{"}compartment{"}"}/{"{"}id{"}"}/{"{"}type{"}"}, y un probador que responde: ¿está este tipo en este compartment, mediante qué parámetros, y qué búsqueda ejecuta el servidor? cmp-rail-label = Definiciones de compartment cmp-rail-heading = Compartimentos +cmp-degraded = Las definiciones de compartimento no se pudieron cargar de este servidor en este momento — la auto-llamada a /CompartmentDefinition falló (con autenticación habilitada esto suele significar que el token de servicio saliente falta o es inválido). La página reintenta en la siguiente petición. cmp-rail-note = Las definiciones base vienen con el servidor (generadas desde la especificación FHIR). Editarlas implica una capa de overrides por tenant — pregunta abierta en el issue. cmp-tabs-label = Secciones del compartment cmp-tab-definition = Definición