mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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:
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,
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user