fix(web-app-dashboard): close req-019 — silent-check-sso postMessage + PKCE-safe persona Login + variant=1 gates

Root causes diagnosed inside the running container per
docs/agents/action/iteration/playwright.md, fixed in repo, validated
via inner-loop rerun-spec.sh, and finally re-deployed via FULL matrix
variant=0 (all dashboard tests green) and reuse-kept variant=1
(persona/asset/login-swap tests collapse cleanly via env + test.skip
gates, 2 passed + 5 skipped, failed=0).

- templates/nginx/sso.html.conf.j2: serve the standard Keycloak
  silent-check-sso body — `parent.postMessage(location.href,
  location.origin)`. Without that script, the hidden iframe never
  reports its URL back to the parent and `keycloak.init()` hangs
  forever; the role's OIDC adapter then never wires its click
  interceptor and the persona helper races into the static `href`
  without PKCE state.
- templates/javascript/oidc.js.j2: set `window.__oidcLoginReady = true`
  after `wireLoginLogoutClicks()` so persona helpers can detect that
  the JS-wrapped click handler is in place BEFORE clicking. The flag
  name SPOT is `OIDC_LOGIN_READY_FLAG` in
  `roles/test-e2e-playwright/files/personas/utils/keycloak.js`.

- personas/utils/keycloak.js: new `clickOidcLoginLink(page, loginLink)`
  helper — single source of truth for the OIDC-link click ritual
  (waitFor visible → waitFor `__oidcLoginReady` → click → waitForURL
  `/openid-connect/auth/`). Replaces the inline duplicated block in
  biber.js + admin.js, and removes the static-href direct-nav fallback
  that broke PKCE-enforced clients silently.
- personas/biber.js + admin.js: delegate to `clickOidcLoginLink` and
  drop the `^...$` regex anchors on the Login link locator. Bootstrap
  `data-bs-toggle="tooltip"` moves the link's `title` into
  `data-bs-original-title` and the accessibility tree pulls THAT into
  the link's accessible name (e.g. "Login to Infinito.Nexus on
  infinito.example") — the anchored regex matched zero links. The
  `\s*` between `log` and `in` keeps the substring pattern from
  matching `Logout`/`logoff`.

- templates/playwright.env.j2: render
  `PERSONA_{BIBER,ADMINISTRATOR}_BLOCKED` from
  `services.oidc.enabled`. Variant=1 disables every shared service —
  with OIDC off there is no Login link to click, so the persona
  scenarios MUST collapse to a clean skip instead of failing loudly.
- files/playwright.spec.js: add `test.skip(\!cdnEnabled || \!logoutEnabled, ...)`
  on the asset-loading test and `test.skip(\!oidcEnabled, ...)` on the
  Login → Account swap test. The bespoke tile-reachability loop
  already skips cleanly when `DASHBOARD_TARGET_ROLES_JSON` is empty,
  so no further gate needed there.

- TODO.md: removed. The autonomy escape clause is satisfied.
- docs/requirements/019-playwright-meta-services-parity.md: strike
  through `web-app-dashboard` row (v0+v1 closed) and document the
  silent-check-sso / `__oidcLoginReady` / Bootstrap tooltip
  contracts inline in the row's notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.7 committed 2026-05-11 16:10:06 +02:00
1 parent 60b7237d8d
commit 94a80db92a
9 files changed
+83 -73

No files matched your search

@@ -76,7 +76,7 @@ Legend (`v0` / `v1` / `v2`): ⏳ untested, ✅ passed (full Per-role flow incl.
| --- | ---: | --- | --- | --- | --- | --- | --- |
| ~~`web-app-prometheus`~~ | 173 | ✅ | ✅ | ✅ | ✅ | | oauth2-proxy gates the role on `web-app-prometheus-administrator`; biber lacks the role so the proxy denies the session and biber has no in-app surface to drive a logout from — opt out via `PERSONA_BIBER_BLOCKED=true` (Rule 11). The administrator persona runs the standard oauth2-proxy → Keycloak chain. Bespoke `metricz`, `dashboard-to-prometheus admin SSO`, and `biber-denied-access` tests cover the SPOT-owned probes. Logout-icon injected via `templates/javascript.js.j2` (gated on services.oauth2.enabled) because the upstream UI ships no in-app logout. Variant=1 collapses oauth2 → PERSONA_ADMINISTRATOR_BLOCKED renders true via env and bespoke tests `safeSkipUnlessEnabled("oauth2")` |
| ~~`web-app-matomo`~~ | 168 | ✅ | ✅ | ✅ | ✅ | | admin-only role: persona stubs explicit-skipped via `PERSONA_BIBER_BLOCKED=true` / `PERSONA_ADMINISTRATOR_BLOCKED=true` in env (Rule 11); bespoke "matomo administrator" test covers the admin journey via matomo's own login form. The biber-deny test gates on `isServiceEnabled("oauth2")` and parks until matomo's oauth2-proxy gate is wired (TODO in `meta/services.yml`) |
| `web-app-dashboard` | 162 | ✅ | ✅ | ⏳ | ⏳ | | |
| ~~`web-app-dashboard`~~ | 162 | ✅ | ✅ | ✅ | ✅ | | OIDC silent-SSO chain ships `parent.postMessage(location.href, location.origin)` in `templates/nginx/sso.html.conf.j2` so `keycloak.init()` can complete; `oidc.js` then sets `window.__oidcLoginReady` after wiring the click interceptor, and the shared persona helper waits for that signal before clicking Login (avoids the static-href fallback that would skip PKCE). Variant=1 disables every shared service — env renders `PERSONA_{BIBER,ADMINISTRATOR}_BLOCKED` from `services.oidc.enabled`, and the asset / login-swap tests skip cleanly on `cdn|logout|oidc`-disabled |
| ~~`web-svc-cdn`~~ | 144 | ✅ | ✅ | ✅ | ✅ | | infra role; bespoke "cdn index served under canonical domain with TLS" test covers the surface — persona stubs collapse to the auth-less skip (no APP_BASE_URL surface), no further fix needed |
| ~~`web-app-mailu`~~ | 139 | ✅ | ✅ | ✅ | ✅ | | bespoke `dashboard → mailu sso → admin → logout` and `biber → email → administrator → receives` tests own the persona coverage (they exercise mailu's iframe-wrapped auth chain directly), both `safeSkipUnlessEnabled("oidc")`-gated so the no-OIDC variant collapses cleanly; the shared persona scenarios route through the dashboard main-frame Account menu and depend on the dashboard OIDC silent-SSO that is currently in escape, so PERSONA_BIBER_BLOCKED + PERSONA_ADMINISTRATOR_BLOCKED collapse them cleanly |
| ~~`web-app-keycloak`~~ | 130 | ✅ | ✅ | ✅ | ✅ | | auth-provider exception: generic persona scenarios are exempt; bespoke "master-realm super administrator", "normal-realm administrator", "normal-realm biber" tests cover the persona contract via the realm account UI. Variant=1 disables LDAP federation; the bespoke biber test collapses via `safeSkipUnlessEnabled("ldap")` — biber MUST NEVER be seeded directly via kcadm, only the administrator persona is seeded for the headless ops loop |
@@ -26,6 +26,7 @@ const {
readEnv,
safeIsEnabled,
performKeycloakLogin,
clickOidcLoginLink,
inAppLogout,
assertUnauthenticatedLanding,
assertCspInjections,
@@ -94,40 +95,17 @@ async function runAdminFlow(page, opts = {}) {
let keycloakRoundTripCompleted = false;
if (adminUsername && adminPassword) {
if (oidcEnabled && !page.url().includes("openid-connect/auth")) {
// Substring match (NO `^...$` anchors) — Bootstrap's `data-bs-toggle="tooltip"`
// moves the `title` attribute into `data-bs-original-title` and the
// accessibility tree picks that into the link's accessible name (e.g.
// "Login to Infinito.Nexus on infinito.example"). An anchored regex
// misses such links entirely. The `\s*` between `log` and `in` keeps the
// pattern from matching "Logout"/"logoff" etc.
const loginLink = page
.getByRole("link", { name: /^\s*(log\s*in|sign\s*in|login|sso|admin)\s*$/i })
.or(page.getByRole("button", { name: /^\s*(log\s*in|sign\s*in|login|sso|admin)\s*$/i }))
.getByRole("link", { name: /log\s*in|sign\s*in|sso|admin/i })
.or(page.getByRole("button", { name: /log\s*in|sign\s*in|sso|admin/i }))
.first();
// `Locator.isVisible(options)` is a snapshot check that does NOT
// poll for visibility — the `timeout` option there only governs
// locator resolution. `waitFor` is the polling primitive, so use
// it here so an OIDC-driven UI (e.g. the dashboard, where
// oidc.js renders the Login link asynchronously after the
// runtime JS loader fetches the script from the CDN) has time to
// surface the link before we try to read its href.
const linkVisible = await loginLink
.waitFor({ state: "visible", timeout: 20_000 })
.then(() => true)
.catch(() => false);
if (linkVisible) {
// The role's own UI may intercept clicks asynchronously (e.g.
// dashboard's oidc.js wraps the Login link in a JS handler that
// calls keycloak.login() once the adapter has loaded). When the
// adapter isn't ready yet the click falls through to the native
// href which already points at openid-connect/auth. Prefer the
// direct navigation when the href is exposed, and fall back to
// a real click otherwise; then wait for the OIDC URL to appear
// either way.
const href = await loginLink.getAttribute("href").catch(() => null);
if (href && /openid-connect\/auth/.test(href)) {
await page.goto(href, { waitUntil: "domcontentloaded" }).catch(() => {});
} else {
await loginLink.click().catch(() => {});
await page
.waitForURL(/openid-connect\/auth/, { timeout: 15_000 })
.catch(() => {});
}
}
await clickOidcLoginLink(page, loginLink);
}
if (page.url().includes("openid-connect/auth")) {
await performKeycloakLogin(page, adminUsername, adminPassword, canonicalDomain);
@@ -26,6 +26,7 @@ const {
readEnv,
safeIsEnabled,
performKeycloakLogin,
clickOidcLoginLink,
inAppLogout,
assertUnauthenticatedLanding,
assertCspInjections,
@@ -95,40 +96,17 @@ async function runBiberFlow(page, opts = {}) {
let keycloakRoundTripCompleted = false;
if (biberUsername && biberPassword) {
if (oidcEnabled && !page.url().includes("openid-connect/auth")) {
// Substring match (NO `^...$` anchors) — Bootstrap's `data-bs-toggle="tooltip"`
// moves the `title` attribute into `data-bs-original-title` and the
// accessibility tree picks that into the link's accessible name (e.g.
// "Login to Infinito.Nexus on infinito.example"). An anchored regex
// misses such links entirely. The `\s*` between `log` and `in` keeps the
// pattern from matching "Logout"/"logoff" etc.
const loginLink = page
.getByRole("link", { name: /^\s*(log\s*in|sign\s*in|login|sso)\s*$/i })
.or(page.getByRole("button", { name: /^\s*(log\s*in|sign\s*in|login|sso)\s*$/i }))
.getByRole("link", { name: /log\s*in|sign\s*in|sso/i })
.or(page.getByRole("button", { name: /log\s*in|sign\s*in|sso/i }))
.first();
// `Locator.isVisible(options)` is a snapshot check that does NOT
// poll for visibility — the `timeout` option there only governs
// locator resolution. `waitFor` is the polling primitive, so use
// it here so an OIDC-driven UI (e.g. the dashboard, where
// oidc.js renders the Login link asynchronously after the
// runtime JS loader fetches the script from the CDN) has time to
// surface the link before we try to read its href.
const linkVisible = await loginLink
.waitFor({ state: "visible", timeout: 20_000 })
.then(() => true)
.catch(() => false);
if (linkVisible) {
// The role's own UI may intercept clicks asynchronously (e.g.
// dashboard's oidc.js wraps the Login link in a JS handler that
// calls keycloak.login() once the adapter has loaded). When the
// adapter isn't ready yet the click falls through to the native
// href which already points at openid-connect/auth. Prefer the
// direct navigation when the href is exposed, and fall back to
// a real click otherwise; then wait for the OIDC URL to appear
// either way.
const href = await loginLink.getAttribute("href").catch(() => null);
if (href && /openid-connect\/auth/.test(href)) {
await page.goto(href, { waitUntil: "domcontentloaded" }).catch(() => {});
} else {
await loginLink.click().catch(() => {});
await page
.waitForURL(/openid-connect\/auth/, { timeout: 15_000 })
.catch(() => {});
}
}
await clickOidcLoginLink(page, loginLink);
}
if (page.url().includes("openid-connect/auth")) {
await performKeycloakLogin(page, biberUsername, biberPassword, canonicalDomain);
@@ -27,6 +27,13 @@
const { expect } = require("@playwright/test");
// SPOT for the role-side OIDC adapter readiness contract. A role whose
// `templates/javascript/oidc.js.j2` wraps its Login link in a JS click
// handler (e.g. `keycloak.login()` with PKCE) MUST set this flag on
// `window` after the click interceptor is wired, so persona helpers can
// click the link without racing the adapter.
const OIDC_LOGIN_READY_FLAG = "__oidcLoginReady";
async function performKeycloakLoginForm(target, username, password) {
const usernameField = target
.getByRole("textbox", { name: /username|email/i })
@@ -59,6 +66,34 @@ async function performKeycloakLogin(page, username, password, canonicalDomain) {
.toContain(canonicalDomain);
}
// Click a role's in-app Login link to start the OIDC chain. Waits for
// the role's adapter to signal readiness (OIDC_LOGIN_READY_FLAG) before
// clicking, so the click hits the JS-wrapped handler (which stores
// PKCE state) and not the raw `href` (which would skip PKCE and break
// the post-login token exchange on PKCE-enforced clients). The 15s
// fallback covers roles whose Login link is purely static. Returns
// true when the navigation reached `openid-connect/auth`.
async function clickOidcLoginLink(page, loginLink) {
const linkVisible = await loginLink
.waitFor({ state: "visible", timeout: 20_000 })
.then(() => true)
.catch(() => false);
if (!linkVisible) return false;
await page
.waitForFunction(
(flag) => window[flag] === true,
OIDC_LOGIN_READY_FLAG,
{ timeout: 15_000 },
)
.catch(() => {});
await loginLink.click().catch(() => {});
await page
.waitForURL(/openid-connect\/auth/, { timeout: 15_000 })
.catch(() => {});
return page.url().includes("openid-connect/auth");
}
async function performKeycloakLoginExpectingDenial(page, username, password, canonicalDomain) {
await performKeycloakLoginForm(page, username, password);
@@ -81,7 +116,9 @@ async function performKeycloakLoginExpectingDenial(page, username, password, can
}
module.exports = {
OIDC_LOGIN_READY_FLAG,
performKeycloakLoginForm,
performKeycloakLogin,
clickOidcLoginLink,
performKeycloakLoginExpectingDenial,
};
-6
View File
@@ -1,6 +0,0 @@
# Todos
## Req 019 rollout — deploy gate
- The persona scenarios (`biber: dashboard → app → universal logout`, `administrator: dashboard → prometheus → app → universal logout`) fail after a real successful Keycloak login: the dashboard's `oidc.js` keeps `authenticated=false` after the post-login redirect, so the Account/Logout menu never surfaces and the persona helper has nothing to click. The dashboard's own `keycloak-js` init / silent-SSO chain needs fixing — once `keycloak.authenticated` correctly flips to true after a code-flow round-trip the persona helpers (now waiting via `Locator.waitFor` and only accepting iframe URL fallbacks) will close the loop without further changes.
- Deferred per the [autonomy escape clause](../../docs/requirements/019-playwright-meta-services-parity.md#autonomy).
@@ -423,6 +423,9 @@ const dashboardJsBaseUrl = normalizeBaseUrl(process.env.DASHBOARD_JS_BASE_URL ||
const matomoBaseUrl = normalizeBaseUrl(process.env.MATOMO_BASE_URL || "");
const matomoEnabled = (process.env.MATOMO_ENABLED || "").toLowerCase() === "true";
const cssEnabled = process.env.CSS_ENABLED.toLowerCase() === "true";
const oidcEnabled = (process.env.OIDC_SERVICE_ENABLED || "").toLowerCase() === "true";
const cdnEnabled = (process.env.CDN_SERVICE_ENABLED || "").toLowerCase() === "true";
const logoutEnabled = (process.env.LOGOUT_SERVICE_ENABLED || "").toLowerCase() === "true";
const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN);
const sharedCssPrefix = buildAssetPathPrefix(cdnBaseUrl, "/_shared/css");
@@ -467,6 +470,8 @@ test("dashboard enforces Content-Security-Policy and exposes canonical domain fr
test("dashboard loads core css, javascript, simpleicons, and logo assets", async ({ page }) => {
test.skip(!cssEnabled, "Skipped: shared CSS service is disabled in this deployment");
test.skip(!cdnEnabled, "Skipped: shared CDN service is disabled in this deployment");
test.skip(!logoutEnabled, "Skipped: shared logout service is disabled in this deployment");
const diagnostics = attachDiagnostics(page);
const documentResponse = await page.goto("/");
@@ -572,6 +577,7 @@ test("dashboard integrates matomo tracking assets", async ({ page }) => {
});
test("dashboard login automatically switches Login to Account and exposes Logout under Account", async ({ page }) => {
test.skip(!oidcEnabled, "Skipped: OIDC service is disabled in this deployment");
const diagnostics = attachDiagnostics(page);
await page.goto("/");
@@ -175,6 +175,11 @@ async function initAuthUI() {
syncAuthMenuVisibility(authenticated);
wireLoginLogoutClicks();
// Persona-helper readiness contract — flag name SPOT is
// `OIDC_LOGIN_READY_FLAG` in
// `roles/test-e2e-playwright/files/personas/utils/keycloak.js`.
// Must stay in sync with that constant.
window.__oidcLoginReady = true;
// Schedule token refresh only if we are authenticated
async function scheduleRefresh() {
@@ -1,16 +1,19 @@
# Serve a static silent-check-sso.html file directly from memory
location = {{ DASHBOARD_LOCATION_SILENT_CHECK }} {
default_type text/html;
add_header X-Frame-Options "SAMEORIGIN";
add_header Cache-Control "no-store";
# The body MUST contain `parent.postMessage(location.href, location.origin)` —
# keycloak-js loads this page in a hidden iframe during silent SSO check and
# waits for that postMessage to detect the auth state. Without the script
# `keycloak.init()` never resolves and the role's OIDC adapter stays hung.
return 200 '<!DOCTYPE html>
<html>
<head>
<title>Silent SSO</title>
</head>
<body>
Checking SSO...
<script>parent.postMessage(location.href, location.origin);</script>
</body>
</html>';
}
@@ -13,6 +13,15 @@ MATOMO_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') }}
CSS_ENABLED={{ lookup('config', application_id, 'services.css.enabled') }}
CANONICAL_DOMAIN={{ lookup('domain', application_id) | dotenv_quote }}
# The biber/administrator persona scenarios drive the dashboard's OIDC
# login round-trip; with OIDC disabled (matrix variant exercising the
# no-shared-services path) the dashboard renders no Login link and
# the persona helpers have nothing to authenticate against. Collapse to
# PERSONA_*_BLOCKED in that variant so the helpers skip cleanly
# instead of failing loudly.
PERSONA_BIBER_BLOCKED={{ (not (lookup('config', application_id, 'services.oidc.enabled') | bool)) | string | lower }}
PERSONA_ADMINISTRATOR_BLOCKED={{ (not (lookup('config', application_id, 'services.oidc.enabled') | bool)) | string | lower }}
# Tile-reachability targets: every role declaring
# `services.dashboard.{enabled,shared}`. The dashboard spec
# parameterises one tile-reachability test per entry.