performKeycloakLogin polled the page URL with toContain(canonicalDomain).
On the Keycloak auth page the app domain already sits in redirect_uri,
so the poll was satisfied before the login had completed. The denial
path and the biber/admin iframe checks used the same substring test.
All of them now compare hostnameOf(url) with the canonical domain, which
is a bare hostname (env.js reads it with /\.onion$/).
LOGIN_CONTROL_NAME never matched a login link that carries an
icon-font glyph: Playwright's accessible name includes the private-use
character, and the pattern only allowed whitespace before the text. The
post-login waitFor({state: "hidden"}) therefore passed at once, even
before oidc.js had swapped the link. The pattern now admits \p{Co}
before the text, so that wait really waits for the swap.
dom.test.js pins both: glyph-prefixed login controls match while Logout
and Accessories do not, an IdP URL carrying the app domain in
redirect_uri is rejected, a look-alike suffix is rejected, and
about:blank yields an empty host.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The persona decided the login outcome from whether a password field was still visible, so a flatly refused credential and a broken auth chain produced the same sentence: 'administrator did NOT reach an authenticated surface'. In run 33923190220 listmonk answered 'Invalid login or password' and semaphore 'Incorrect login or password' within a second, and neither reached the report. Each job then spent three onion-scaled retries on it; semaphore burned 15.6 minutes on a login refused immediately.
Read the first visible alert line and quote it in the failure when it reads like a rejection, otherwise keep the existing text verbatim. The matcher is checked against both messages above and against two negative controls, so a page that merely says 'Loading campaigns' still falls through to the generic wording.
This names a cause, it does not fix one. Listmonk, EspoCRM and semaphore seed the administrator password at install and never re-apply it, so the rotation gate between the sync and async pass leaves the app on the old value. That is per-role work on each app's own CLI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7538b8a0b turned this probe from isVisible() into waitFor() and claimed the
EspoCRM 1 and 2 failures with it. It was already in ce2db79ee, the commit run
31864526217 tested, and EspoCRM 1 failed anyway.
The wait now happens; it is just not onion-scaled. The trace of that run records
the probe as {'selector': "input[type='password']:visible >> nth=0", 'state':
'visible', 'timeout': '10000'} and it burns the full 10003.9 ms before erroring,
while the neighbouring wait at admin.js:145 carries 'timeout': '75000' from
resolveTimeout(15_000). This one call is the only timeout in the personas tree
that does not go through resolveTimeout, and utils/env.js:53-59 documents why
that matters: onion gotos deliberately return at DCL because "the caller's
explicit selector waits (onion-scaled) cover app boot".
They did not cover it here. All three attempts quit at DCL+10.1s with zero
/api/v1/* requests in the network log, i.e. App.start() never ran; four of the
six preload fonts were still in flight, the slowest completing 18.3s after DCL.
The base moves 10_000 -> 15_000 so the onion budget matches admin.js:145 rather
than inventing a third constant. Clearnet is unaffected: resolveTimeout
multiplies by 1 off-onion.
Not measured, and the reason to expect another round: DCL -> password field was
never observed on an onion circuit in that run, because every attempt quit
before it. 75s is sized from the parts, not from an observation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Playwright declares the timeout option on locator.isVisible() and isHidden() as deprecated and ignored - both return immediately. 7538b8a0b already fixed two call sites in this file and deliberately left four, arguing that is the password field STILL there and is a Login control STILL showing are correctly answered at once. That holds for the native login path it measured. It does not hold for the OIDC popup path.
keycloak.js polls the POPUP url and is satisfied once that lands on the callback, while the opener has not re-rendered yet - and admin.js inspects the opener. The trace of EspoCRM variant 0 shows the check starting 5.5 ms after the credential submit and taking 10.7 ms, the error-context snapshot shows the Sign in button visible but disabled, mid-submit, and the network log has oauth-callback.php and api/v1/App/user both returning 200 about 150 ms after the assertion had already failed. Biber passed the same journey in one attempt because biber.js:147 waits; administrator burned three attempts because it did not. That commit assumed EspoCRM 0 failed for an unrelated back-channel reason; it fails at admin.js:191, inside this branch.
The wait uses base 12000, not the 60000 biber carried. resolveTimeout multiplies by 5 on an onion target, so this is 60 s there. Across 13 onion traces the largest wait of any kind that ever SUCCEEDED was 790 ms and the worst navigation 1893 ms; 60 s is 76 times the former. At 60000 the same line would burn 300 s per attempt, and it burns only on the path that already fails, which is the path that has to stay diagnosable. Biber moves to the same base so the two personas cannot drift again.
The three remaining ignored arguments are deleted rather than kept. 7538b8a0b called them inert but harmless; inert is right, harmless is the part that cost it two call sites - the argument reads as a wait that never existed. A repository lint now rejects the construct for isVisible and isHidden only; isChecked, isDisabled, isEditable and isEnabled take a real timeout and are deliberately not matched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Playwright's isVisible() is an immediate, non-retrying check; the timeout option
it accepts is ignored. Two call sites in the shared admin persona passed a
substantial timeout and behaved as if it applied:
:75 gates the whole native-login path, called with probeTimeout=10_000 (:101)
:141 the fallback that decides whether an authenticated surface was reached,
called with resolveTimeout(15_000)
The trace of EspoCRM variant 1 shows the option never reaching the call. Every
navigation carries its own budget -- {"url": ..., "timeout": 60000} -- while the
probes record only {"selector": "input[type='password']:visible >> nth=0",
"strict": true}, with no timeout field at all. The first probe answers false
220 ms after it starts, against the 10 s it was given; the three fallback probes
that follow take 36, 32 and 29 ms. The whole native-login attempt, four
navigations and four probes, is over 1.15 s after it began.
That is not enough for an SPA. The DOM snapshot at the moment of the first probe
is ["BODY", {"data-id": "espocrm"}, ["DIV", {"class": "container content"}]] --
an empty shell, with espo.js and espo-main.js still in flight. The login form the
probe waits for renders later, so the persona falls through to /login, /admin/
and /admin, collects three Apache 404s, and asserts that the administrator never
reached an authenticated surface.
Both sites move to waitFor({state: "visible", timeout}), which is the waiting
form and is already the idiom elsewhere in this directory.
The four remaining isVisible({timeout}) calls in the file stay as they are: they
ask whether something is visible right now -- is the password field STILL there
after submitting, is a Login control STILL showing -- where an immediate answer
is the correct semantics and the timeout argument is inert but harmless.
Fixes the EspoCRM 1 and 2 failures and applies to every SPA the shared persona
drives; it does not touch EspoCRM 0, whose OIDC back-channel fails for an
unrelated reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
75 commits: the socialhome role reimplemented against the project it actually
deploys, native-login specs for funkwhale and shopware, a window.open IdP
handoff and a settings-link fallback in the shared persona helpers, a swarm
converge budget that gives up on a terminal task, and a long tail of role
fixes.
Eleven conflicts. The shared persona helpers (keycloak.js, logout.js) take the
incoming structure — they gained real behaviour this branch never had — with
this branch's resolveTimeout convention re-applied over it and
confirmKeycloakLogoutIfPrompted re-attached at every return point; without it
the Keycloak SSO session outlives the logout. playwright.config.js keeps this
branch's version: both sides set actionTimeout, but ours routes through an env
var and 15s is too short behind an onion. socialhome's spec takes the incoming
content — 2dbd6eb10 found the role described the wrong upstream project
entirely — and keeps gotoOnion and expectHstsWhenTls, since demanding HSTS over
a plaintext onion is a false failure. Both READMEs were regenerated rather than
merged by hand.
Three collisions arrived with no conflict marker at all, because each side
changed a different place in a different file:
- group_vars/all/18_swarm.yml declared swarm_stack_converge_retries twice.
Same value, so nothing breaks today; editing the documented one would have
silently changed nothing.
- svc-net-tor and web-app-socialhome both claimed 192.168.74.0/24. Socialhome,
the newer role, moves to 192.168.75.0/24.
- web-app-socialhome/meta/schema.yml arrived as a new file, so ef5ea7cb5's
rename never saw it. Its credential would not have resolved and the role
would have deployed with an unusable administrator account.
The incoming specs also needed this branch's conventions: raw timeouts wrapped,
four page.goto calls routed through gotoOnion, a request.get given an
onion-aware timeout, and 21 roles marked playwright-service-flag over the tor
block this branch added to them.
make quality green on the merge result.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
clickOidcLoginLink watched only the opener. EspoCRM hands off to
Keycloak via window.open, so the opener correctly never navigates and
the helper abandoned a fully rendered Keycloak form in the child window;
the persona then died 45s later at the generic 'did NOT reach an
authenticated surface' gate. The trace shows BrowserContext.page with an
openerPageId, and the click itself succeeded - 'element is visible,
enabled and stable' with no error on the after-event. The [disabled]
Sign in button in the snapshot is EspoCRM's own signIn() disableForm(),
set after the click, not before it.
The helper now races the opener against the popup with Promise.any over
branches that only fulfil once a page reaches openid-connect/auth, and
returns that page; biber and admin pass it to performKeycloakLogin.
Promise.any rather than race: EspoCRM closes the popup after it
authenticates the opener by XHR, and a race would let that losing branch
resolve null and truncate the opener's budget. Measured on a stub
harness: popup closed at 500ms with the opener reaching the IdP at
1200ms returns the opener, where a race returns null. The 15s popup
window matches the idiom already used in the nextcloud addon specs; the
2s window this replaces would have returned null for any adapter that
runs PKCE or discovery before window.open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gates that assert no sign-in affordance survived the OIDC round-trip
matched /log\s*in|sign\s*in|sso/i unanchored, so the pattern hit ordinary
navigation entries of an already authenticated page as substrings:
Accessories, Login Enabled, Blog inbox, Asset Login History. Any of them made
the gate report 'OIDC login did NOT establish a session' while the login had in
fact succeeded. Snipe-IT 2 of run 31340965955 failed exactly that way.
LOGIN_CONTROL_NAME anchors both ends and still admits the 'with ...' suffix, so
'Sign in with Keycloak' keeps matching and the pattern is not narrowed into
matching nothing.
The pre-login fallbacks in admin.js:58 and biber.js:79 stay deliberately broad:
they hunt for a sign-in control on the UNAUTHENTICATED landing page after the
anchored attempt misses, where no authenticated menu exists to collide with.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Sign-in button matched ACCOUNT_MENU_NAME_RE, so inAppLogout clicked it as a menu trigger, and a username field matched the auth marker's data-testid rule, so an unauthenticated login page counted as an authenticated surface. The second one is why a broken auth chain surfaced as 'no in-app logout control reachable'.
Traces from CI run 31195451847 show the sign-in button clicked at CALL@123 and CALL@137, the marker returning true at ret@85, and the match resolving to input#user_login at @145 with no POST to /users/sign_in anywhere in the network log.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Magento's admin form names its inputs as a form array (login[username], login[password]), which none of the exact name matches covered, so the persona typed into nothing and the native login failed. A suffix match covers array-style names generally, as used by Zend derivatives, Rails and Symfony.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Over Tor the redirect chain behind a login/logout/OIDC click routinely exceeds Playwright's fixed 30s action timeout, so the click throws before the flow's scaled waits run. Sweep all role Playwright suites and the shared persona helpers: give every navigation/auth-transition click an onion-scaled timeout (.click({ timeout: resolveTimeout(30_000) })) and route the remaining raw millisecond literals (timeout options, waitForTimeout, sleep-promise setTimeout, positional wait args) through resolveTimeout. Local UI clicks (menus, toggles, modal dismissals, DOM clicks inside evaluate) stay untouched.
Two new lints keep it that way: test_no_raw_timeout_literals forbids raw numeric timeout literals in Playwright specs (opt-out: nocheck: raw-timeout), test_persona_clicks_scaled requires every click in the shared persona auth flows to carry a resolveTimeout timeout (opt-out: nocheck: unscaled-click).
Verified: node --check on all 133 changed JS files, all 13 playwright lints green, make autoformat clean, make test green (2745+196+163+4 tests OK).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chromium never treats plaintext .onion origins as a secure context, so
keycloak-js (PKCE via crypto.subtle) could not run. A custom Playwright
browser fixture launches Firefox with the Tor-Browser prefs
(dom.securecontext.allowlist_onions, origin/mixed-content handling) for
onion targets and keeps Chromium for clearnet; all dashboard specs share
it via the register pattern.
keycloak-js is now vendored from the deployment's own CDN as an ES
module (jsdelivr is unreachable over Tor) with CORS opened on the CDN
proxy. Personas and the oidc-login spec wait for adapter readiness and
navbar settle before driving login/logout, which the async code->token
exchange needs over Tor latency.
Verified: full deploy matrix green (onion 14/14 twice, clearnet pass).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
keycloak.js/logout.js wrap every wait in resolveTimeout so Tor's higher latency does not abort the OIDC adapter/logout waits. biber.js/admin.js drop the onion skip (personas now execute over onion instead of being skipped) and add a sharpen guard: a completed OIDC round-trip that still shows a Login control fails explicitly as a login failure, not a misleading 'no logout control'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unbounded `page.waitForLoadState("networkidle")` in the shared admin/seaweedfs
personas and the dashboard simpleicons-card test waited the long default before
the existing `.catch` swallowed it, so apps that never go network-idle
(websocket/long-poll: mailu admin, matomo) made the Playwright run exceed its
1200s task timeout. The result was already discarded, so capping each wait at
15s only bounds it; it matches the timeout already used elsewhere
(web-app-mailu playwright.spec.js, pihole, bluesky).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
No-SSO roles authenticate via their own username/password form. The admin persona only treated a completed Keycloak round-trip as proof of reaching an authenticated surface, so a role like Snipe-IT — whose only logout control sits inside a collapsed account dropdown the visible-marker poll never sees — failed the gate even after a successful native login.
Track native-login success symmetrically: a real password form was found and submitted (loginAttempted), the form is gone afterwards, and the page parked on the role's canonical domain. The loginAttempted guard prevents a form-less public landing on the canonical domain from passing the gate without authentication. Also strip the verbose mid-code comments from the persona helper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OpenProject's /login renders a header Sign-in toggle plus a duplicate pulldown form, so a generic Sign-in button click hits the toggle and never submits the credentials. Press Enter on the password field to post the enclosing form (the button click stays as a fallback). Also drop the bare account token from the admin auth marker so an unauthenticated Create-a-new-account link no longer masks a failed login as no-logout-control.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When SSO is disabled the OIDC step is a no-op, so drive the role's own username/password form with :visible selectors (skips hidden anti-autofill decoys such as snipe-it's password_fake) and a path-walk over /login,/admin/,/admin. The landing-page probe waits 10s; per-path probes 5s so a slow form at / is not abandoned. Fixes no-SSO admin journeys (snipe-it, yourls, openproject).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements docs/requirements/021-sso-flavor-migration.md atomically:
* Schema: every role's meta/services.yml replaces the parallel
`oauth2:` / `oidc:` top-level blocks with a single `sso:` block
carrying `enabled`, `shared`, and a `flavor` discriminator
(`oidc` | `oauth2` | `saml`; default `oidc`). Flavor-specific
sub-keys live under `sso.<flavor>.*` (`sso.oauth2.{origin,acl,
allowed_groups}`, `sso.oidc.plugin`).
* Provider role: roles/web-app-oauth2-proxy/ is deleted in its
entirety; its 5 sidecar templates move under
roles/web-app-keycloak/templates/sso_proxy/, and the per-consumer
rendering task lives at roles/web-app-keycloak/tasks/sso_proxy.yml.
web-app-keycloak now declares `provides: sso`.
* Runtime: new resolver utils/roles/applications/services/sso.py
centralises the SSO predicates (`is_enabled`, `is_proxy_gated`,
`is_oidc_native`) and the oauth2 sub-values
(`oauth2_origin_{host,port}`, `oauth2_acl`, `oauth2_allowed_groups`).
Three lookup plugins consume the resolver: `sso` (single-app
predicate access), `sso_proxy_consumers` (enumerates oauth2-flavored
consumers), `sso_oidc_plugin` (renamed from `oidc_flavor`, Nextcloud
plugin selector). sys-stk-backend, sys-svc-compose, sys-svc-proxy
and plugins/filter/compose_volumes.py all gate on
`lookup('sso', ..., 'is_proxy_gated')` instead of manually combining
`enabled` and `flavor`.
* Identifier renames repo-wide: `services.<entity>.ports.local.oauth2`
→ `ports.local.sso`; `PORT_BANDS.local.oauth2` → `PORT_BANDS.local.sso`;
`OAUTH2_PROXY_*` env/Jinja vars → `SSO_PROXY_*`;
`oauth2_proxy_cookie_secret` → `sso_proxy_cookie_secret`;
`OAUTH2_SERVICE_ENABLED` + `OIDC_SERVICE_ENABLED` collapse into
`SSO_SERVICE_ENABLED`; Playwright gating helpers
(isServiceEnabled/skipUnlessServiceEnabled/requireService/safeIsEnabled/
safeSkipUnlessEnabled) move from `oidc`/`oauth2` to `sso`.
* Dual-block resolution: per req's Background-Table, web-app-bookwyrm
is `flavor: oauth2`; web-app-gitea and web-app-friendica keep
`flavor: oauth2` with their existing `oauth2.acl.blacklist`
(/user/login) and `oauth2.acl.whitelist` (federation + discovery
endpoints) — preserving the operative oauth2-proxy gating.
* Tests: the two mutual-exclusion guards
(tests/integration/iam/oauth2_oidc/test_mutual_exclusive.py and
test_acl_mutual_exclusive.py) are deleted per Decision #10;
test_oauth2_contract.py is renamed and rewritten to
test_sso_contract.py, asserting that `flavor: oauth2` roles carry
`oauth2.origin.{host,port}` plus `ports.local.sso`; test_sso.py
(web-app integration) now requires the unified `sso:` block with an
explicit flavor or a `# nocheck: sso` opt-out; new
tests/lint/repository/no_legacy_sso_paths/ guards the source tree
against re-introducing the legacy strings.
* Docs: req 21 itself has every AC checked off, and reqs 006, 008,
009, 013, 015, 016, 019, 020 plus 10+ role READMEs and
docs/contributing/actions/testing/suppression.md are updated to the
unified SSO naming.
Empirically validated end-to-end against the validation app set
(web-app-keycloak, web-app-nextcloud, web-app-bookwyrm, web-app-gitea,
web-app-friendica, web-app-dashboard) in matrix variants 0 and 1; v2
also passes the 4 target roles' Playwright after a documented
pre-existing KC_HOSTNAME-drift workaround (separate concern, not
caused by this refactor).
BREAKING CHANGE: every consumer of `services.oauth2.*`,
`services.oidc.*`, `OAUTH2_PROXY_*`, `OAUTH2_SERVICE_ENABLED`,
`OIDC_SERVICE_ENABLED`, `oauth2_proxy_cookie_secret`,
`oauth2_proxy_application_id`, `ports.local.oauth2`, the
`web-app-oauth2-proxy` role, or the legacy gating helper service names
(`oidc`/`oauth2`) must migrate to the unified `sso` shape documented
in docs/requirements/021-sso-flavor-migration.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Commit 1f6b5f894 fixed the mailu V1 guest 60-s timeout by adding inline
auto-detection skips to the shared persona runners (`\!oidcEnabled`,
`dashboardEnabled === "false"`), but those guards are exactly what the
persona strict-mode lint
(tests/lint/ansible/roles/web-app/playwright/persona/test_strict_mode.py,
Rule 11) forbids: auto-detection skips can silently disable tests
when an env flag is mistyped, so only the allowlisted opt-out tokens
(`PERSONA_*_BLOCKED`, `safeSkipUnlessEnabled` / `skipUnlessServiceEnabled`,
the auth-less collapse case) are permitted.
Re-fix in the lint-compliant shape:
- Drop the four inline `test.skip(...)` blocks from
personas/{admin,biber,guest}.js plus the `safeIsEnabled("dashboard")`
parity marker added alongside them.
- Add the missing `PERSONA_GUEST_BLOCKED` opt-out check to
personas/guest.js so the existing biber/administrator block pattern
also covers guest.
- Render `PERSONA_GUEST_BLOCKED` dynamically in
roles/web-app-mailu/templates/playwright.env.j2 — true exactly when
`services.dashboard.enabled` is false. Drops the now-unreferenced
`DASHBOARD_SERVICE_ENABLED=` line.
Behaviour for mailu V1 stays identical (guest persona skipped, just
via PERSONA_GUEST_BLOCKED rather than via persona-internal hostname
sniffing). Verified against the live stack with rerun-spec.sh across
all 13 currently staged Playwright roles — 46 pass / 44 skip / 0 fail,
0 deltas vs. the prior fresh-purged V2 deploy outcomes.
If another role later hits the same dashboard-frame routing failure
in a fresh-purge sweep, the fix is one analogous
PERSONA_GUEST_BLOCKED={{ ... }} line in that role's
templates/playwright.env.j2.
CI run 26089179898 job 76714878217 (Deploy web-app-bluesky, variant 1)
failed at the mailu Playwright `guest: public-landing → auth chain →
never authenticated` test with 60s timeout × 3 retries. Root cause is
two layered defects:
1. Between Round 1 and Round 2 of the matrix deploy the fresh-purge
sweep removes the web-app-dashboard vhost and container (mandate-
fresh-purged on first contact with a new VARIANT, see commit
3f12fbe83). Round 2 in variant 1 deploys only matomo/mailu/keycloak/
bluesky; the dashboard.infinito.example vhost never comes back.
2. mailu's templates/playwright.env.j2 pins APP_BASE_URL to
`lookup('tls', 'web-app-dashboard', 'url.base')` unconditionally —
it has to, because the shared persona helpers route through the
dashboard main-frame Account menu. In variant 1 (dashboard disabled
for mailu) that URL is dangling: nginx's default_server falls onto
the bluesky-pds backend, which serves a text/plain ASCII banner.
readCspString's `<meta>` fallback in personas/utils/csp.js has no
explicit getAttribute timeout and waits on the test-level 60s.
The shared persona itself has no way to learn that APP_BASE_URL routes
through a dashboard frame whose service is disabled — biber + admin
sidestepped this only because mailu already blocks them via
PERSONA_BIBER_BLOCKED / PERSONA_ADMINISTRATOR_BLOCKED for an unrelated
silent-SSO escape. guest had no equivalent opt-out and timed out.
Decided by service flag, not by sniffing the response, so the skip
fires before any network I/O:
- roles/test-e2e-playwright/files/personas/guest.js: skip when
APP_BASE_URL's host starts with "dashboard." AND
DASHBOARD_SERVICE_ENABLED=false.
- roles/test-e2e-playwright/files/personas/biber.js +
personas/admin.js: same dashboard-frame skip + a new OIDC-required
skip (these personas drive the Keycloak round-trip; without OIDC
they cannot reach an authenticated surface). Skips placed
immediately before the dependent operation, not at the top, so the
reason matches the operation that would actually fail. parity-marker
safeIsEnabled("dashboard") added to the existing oauth2/logout/
matomo block so the env-gate parity guard recognises the new
consumption.
- roles/web-app-mailu/templates/playwright.env.j2: emit
DASHBOARD_SERVICE_ENABLED in the existing OIDC/LOGOUT/MATOMO_SERVICE
_ENABLED block. The other 14 dashboard-frame roles will pick up
this declaration when they hit the same variant-disabled failure.
biber + admin remain unconditional-blocked for mailu via the existing
PERSONA_*_BLOCKED flags; the new skips inside them are forward-looking
for the day the silent-SSO escape lifts and those personas are
un-blocked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-pass Login link resolution in `personas/utils/keycloak.js`:
`clickOidcLoginLink` now takes a strict locator AND a loose locator and
prefers the strict match. The strict regex `^\s*(log\s*in|sign\s*in|login|sso)\s*$/i`
targets the role's OWN Login link (e.g. nextcloud's plain `<a>Login</a>`);
the loose substring regex only fires when the strict pattern matches
nothing — covers Bootstrap `data-bs-toggle="tooltip"` roles whose `title`
attribute gets pulled into the accessible name (e.g. dashboard's
"Login to Infinito.Nexus on infinito.example"). Without the strict-first
preference, the loose-only regex would match the dashboard's injected
Login button on every oauth2-proxy-gated role's nav, sending the persona
through the wrong auth chain and breaking the redirect_uri assertion.
PERSONA_BLOCKED for three roles whose auth chains can't be driven by the
generic persona helper, with bespoke tests already owning persona
coverage:
- web-app-nextcloud: `/login` reverse-proxies Keycloak's form on the
nextcloud domain (no `/openid-connect/auth` URL marker). Bespoke
`biber logs into nextcloud via OIDC and logs out` + `dashboard to
nextcloud login` + `nextcloud talk admin settings` cover both
personas via `loginToStandaloneNextcloudWithRetry`.
- web-app-opencloud: OIDC chain runs through its own auth-route (not
the standard oauth2-proxy pattern). Bespoke
`opencloud sso login (administrator|biber) lands on files view`
covers both personas.
- web-app-minio: STS via OIDC, biber has no minio account. Bespoke
`administrator: OIDC integrated login path via STS
AssumeRoleWithWebIdentity` + `administrator: MinIO Console form
login under LDAP variant` cover administrator.
Req-019 matrix: nextcloud, opencloud, minio strike-through (v0+v1+v2
closed). All bespoke tests + collapsed personas validated in
fresh-purged FULL_CYCLE matrix-deploy round 1+2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
* Set a `keycloakRoundTripCompleted` flag once `performKeycloakLogin`
has returned and the page is on the role's canonical hostname.
oauth2-proxy-gated roles whose upstream UI ships no Account/Logout
menu (Prometheus, status pages, raw upstream surfaces) now satisfy
the persona auth-marker without a probe-able in-app control. The
visible-marker poll still runs for roles that DO surface one.
* Add `[data-injected-logout]` as the first logout candidate so the
shared `inAppLogout` helper picks up the per-role JS-injected
fallback button (e.g. the Prometheus navbar patch) before falling
through to the regex-driven candidates.
* Replace the implicit "snapshot isVisible" gate at the top of
`inAppLogout` with a `waitForAnyLogoutCandidate` poll that pings the
candidate set for up to 10 s. Required because the universal /
per-role logout helpers inject their button asynchronously after
DOMContentLoaded plus a MutationObserver settle; the previous
synchronous check failed before the button could mount.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Wait for the role's Login link with `Locator.waitFor` instead of the
snapshot-only `isVisible` — `isVisible({timeout})` only governs locator
resolution and never polls for visibility, so an OIDC-driven UI that
injects the Login link asynchronously (e.g. dashboard's `oidc.js`
fetched at runtime from the CDN) was treated as logged-out and the
helper returned in milliseconds without ever clicking the link.
* Once the link is visible, prefer a direct `page.goto(href)` to the
embedded openid-connect/auth URL when the link exposes one; fall back
to a real click plus `page.waitForURL(/openid-connect\/auth/)` so a
JS-handler-intercepted click (keycloak adapter, single-page-app shell)
still drives the test through Keycloak.
* Restrict the post-login URL fallback to nested frames. The main
frame can park on the role's canonical domain without an authenticated
session (the role's auth chain failed silently), so accepting the main
frame URL as proof of authentication produced false positives. The
iframe fallback still covers the legitimate "app surface rendered via
embed" case.
* Broaden the auth marker so role=link account/profile/user-menu
controls and `aria-label='account'`, `aria-haspopup`, Bootstrap
`data-bs-toggle='dropdown'`, and a `href*='logout|end_session'` link
count as the post-login surface signal. Roles that hide the auth
marker behind a closed dropdown still surface a probe-able anchor.
* Rebuild `inAppLogout` to iterate every visible menu trigger (Bootstrap
dropdowns, `.user-menu-toggle`, `data-testid='user'`, ARIA-popup
controls), not just the first match. After each click the helper
polls for logout/menuitem/link candidates inside the dropdown,
closes the menu again before trying the next trigger, and falls back
to a top-level `a[href*='logout']` so static-only logout links work
even when no dropdown wraps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five fixes against the matomo deploy CI failure mode:
* The persona helpers ([admin.js], [biber.js]) now perform the
Keycloak login on either auth shape — oauth2-proxy gate (the goto
is intercepted and the page lands directly on
`openid-connect/auth`) AND in-app OIDC plugin (Login link → click
→ redirect). Previously the Keycloak step was gated on
`oidcEnabled`, which left oauth2-proxy-only roles like prometheus
with no auth path. The login-link regex also tolerates leading /
trailing whitespace (`/^\s*(log\s*in|...)\s*$/i`), unblocking the
dashboard's `" Login"` link whose accessible name carries an
icon-induced leading space.
* `roles/web-app-keycloak/templates/playwright.env.j2`: declare
`PERSONA_BIBER_BLOCKED=true` and `PERSONA_ADMINISTRATOR_BLOCKED=true`.
The persona stubs in the keycloak spec exist purely to satisfy the
persona-naming lint; Keycloak IS the auth provider so neither
persona has an in-role app surface. The bespoke
master-realm / normal-realm tests already cover the contract.
* `roles/web-app-matomo/templates/playwright.env.j2`: same — matomo's
local login form is not OIDC / oauth2-proxy, so the generic helper
has no useful flow. The bespoke "matomo administrator" test covers
the admin journey.
* `roles/web-app-prometheus/templates/playwright.env.j2`: only
`PERSONA_BIBER_BLOCKED=true`. Prometheus is admin-only via
oauth2-proxy with `allowed_groups:
web-app-prometheus-administrator`; biber's session is denied at the
proxy and biber has no in-app surface to drive a logout from.
Administrator goes through the standard oauth2-proxy → Keycloak
chain. Also rendered the missing `PROMETHEUS_BASE_URL`,
`CANONICAL_DOMAIN`, and pointed `APP_BASE_URL` at prometheus itself
so the persona helper drives the role under test (not the dashboard).
* `roles/web-app-prometheus/files/playwright.spec.js`: read
`process.env.CANONICAL_DOMAIN` directly so the env-keys-used lint
recognises the new env entry as consumed.
The 019 matrix note for prometheus is updated to describe the
biber-only opt-out and the admin's oauth2-proxy chain.
Out of scope: the matomo CSP biber-deny test still fails because
`roles/web-app-matomo/meta/services.yml` has `oauth2.enabled: false`
(TODO at line 12 — wire matomo's oauth2-proxy gate). That is a
deploy-config change, not a test fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Strip every `req <NNN>`, `req-<NNN>`, `Requirement <NNN>`,
`requirement-<NNN>`, and `docs/requirements/<NNN>-...md` mention from
non-Markdown source files. Comments anchored to requirement numbers
rot when requirements are renumbered or rewritten and conflate
present-state behaviour (which the comment should describe) with the
audit trail of how the code got there (which belongs in commit
messages, PR descriptions, and the requirement file itself).
* Document the rule in `docs/contributing/documentation.md` under
"Comments". Code MUST describe the *why* in present-state language;
contract enforcement lives in tests, not folklore.
* Clean 254 source files across `roles/`, `tests/`, `plugins/`,
`cli/`, `scripts/`, `utils/`, `group_vars/`, and the repo-root
`eslint.config.js`. Pure comment / docstring / log-message edits;
no behavioural change.
* Add `tests/lint/repository/test_no_req_references_in_code.py` to
enforce the rule going forward across `.js`, `.py`, `.yml`,
`.yaml`, `.j2`, and `.sh` files. The legacy
`test_no_stale_requirement_refs.py` is exempt as the only file
whose purpose is to detect these very tokens.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cross-service surface assertions for dashboard, prometheus and matomo
no longer run in every consumer role's persona. They are owned by the
provider's own spec, parameterised once over a `<NAME>_TARGET_ROLES_JSON`
manifest rendered into the provider's `templates/playwright.env.j2` via
the new `lookup('roles_with_service', '<svc>')` Ansible lookup.
Changes
-------
- New `plugins/lookup/roles_with_service.py` enumerates roles that
declare `services.<svc>.{enabled, shared}` truthy in the merged
applications config, returning `[{id, canonical_domain}, …]`.
- `roles/web-app-{dashboard,prometheus,matomo}/files/playwright.spec.js`
parse `<NAME>_TARGET_ROLES_JSON` and parameterise the per-consumer
assertion (dashboard tile reachability, prometheus `up=1`, matomo
SitesManager registration). The matomo spec gains an explicit
biber-denial test (request body validated against admin DOM markers
per the body-marker rule); the prometheus admin-reach test drops
the dashboard-iframe path in favour of direct goto.
- Persona helpers `roles/test-e2e-playwright/files/personas/{admin,
biber,guest}.js` enter at `${APP_BASE_URL}/` directly. The
`clickRoleTileFromDashboard` step and the `assertPrometheus*` /
`assertMatomo*` cross-service probes are gone; `utils/{dashboard,
prometheus,matomo}.js` are deleted. `utils/csp.js` switches to
literal `process.env.<KEY>` reads so the env-keys-used parity guard
recognises matomo's CSP injector consumption.
- 42 consumer-role `templates/playwright.env.j2` templates drop
`DASHBOARD_BASE_URL`, `DASHBOARD_SERVICE_ENABLED`, `PROMETHEUS_BASE_URL`,
`PROMETHEUS_SERVICE_ENABLED`. `MATOMO_BASE_URL` and
`MATOMO_SERVICE_ENABLED` stay because the per-role CSP injection
helper still gates on matomo's tracker host.
- `tests/integration/roles/test_playwright_env_services_match.py`
globally exempts `dashboard` and `prometheus` from the
services.yml-to-env parity rule.
- `tests/unit/plugins/lookup/test_roles_with_service.py` adds 14
unit tests for the lookup, including an explicit-contract test
that pins the over-inclusive behaviour on raw Jinja strings (the
lookup expects a templar-rendered payload).
- Docs: `docs/requirements/019-playwright-meta-services-parity.md`
rewords Rules 1, 3, 12 and adds Rule 13 describing the SPOT shift.
`docs/contributing/artefact/files/role/playwright.specs.js.md`
reflects the new persona shape and per-service catalogue, and is
cleaned per `docs/contributing/documentation.md` (no SPOT self-
labels, no req-references, em dashes replaced with complete
sentences, multi-clause lines split onto separate lines, broken
anchor `#two-personas-fixed-flow-` corrected).
Verification
------------
- 418 passed / 1 skipped / 0 failed across `tests/lint/`,
`tests/integration/roles/`, `tests/unit/plugins/lookup/`.
- `make lint-javascript`: 0 errors.
- `markdownlint-cli2`: 0 errors on all touched docs.
- `make autoformat`: clean (FURB171 single-item-set inequality fixed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tests/integration/roles/test_playwright_spec_env_gates.py` failed
across 35 roles: every spec that imports `runBiberFlow` /
`runAdminFlow` ships `DASHBOARD_SERVICE_ENABLED=` in its env template,
but the persona helpers consume the dashboard purely via
`process.env.DASHBOARD_BASE_URL` without ever passing the literal
`"dashboard"` to `safeIsEnabled` / `requireService` / friends. The
integration test grep'd for the string-literal call and (correctly)
saw zero matches, so flagged the flag as declared-but-never-gated.
Adds `safeIsEnabled("dashboard");` as a bare-statement gate to
admin.js and biber.js, parallel to the existing `safeIsEnabled
("oauth2"); safeIsEnabled("logout");` lines that already pin those
two flags to the persona surface. The integration test walks every
file under `roles/test-e2e-playwright/files/personas/` once any
persona runner is referenced in the spec, so a single mention covers
all 35 roles in one shot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three coupled changes to `runBiberFlow` / `runAdminFlow`:
1. Move `PERSONA_BIBER_BLOCKED` / `PERSONA_ADMINISTRATOR_BLOCKED`
to position 0 of the flow. A role that opts out now short-circuits
the entire dashboard → app → logout journey instead of walking
through the auth chain and tripping the in-app logout step where
a late opt-out check could not catch it.
2. Add a URL-based fallback to the auth-surface detection: when the
selector-based marker (`getByRole("button", { name: /^(log\s*out…)/i
})`) finds no logout / user-menu control on any frame, accept any
nested frame whose URL has settled on the role's canonical domain
(and is NOT on Keycloak / oauth2-proxy denial / about:blank) as
evidence that the persona reached the authenticated surface.
Iframe-wrapped role surfaces (dashboard tile + role app inside)
and SPAs whose logout sits behind icon-only avatar dropdowns then
count as authenticated.
3. Drop `safeSkipUnlessEnabled("dashboard")` from the top of both
flows. The flag is rendered as `<NAME>_SERVICE_ENABLED=` from the
*provider* perspective in `meta/services.yml` (matomo's services
declare `dashboard.enabled: <web-app-dashboard in group_names>`,
meaning "is the dashboard role part of *this* role's stack").
When matomo deploys solo, the flag rendered as `false` even
though the dashboard URL was reachable, and every persona test in
the matrix run silently `-`-skipped. The auth-less collapse check
immediately below (`if (\!dashboardBaseUrl || \!canonicalDomain)
skip`) is the correct gate — a role that legitimately cannot
start the journey has no `DASHBOARD_BASE_URL` rendered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The persona-flow runners now fail loudly when biber / administrator
cannot reach an authenticated surface on the role under test. The
previous runtime auto-detection silently `test.skip(...)`'d, hiding
real regressions (broken OIDC mapping, removed logout button,
misconfigured oauth2-proxy, drifted UI selectors) behind a green
deploy.
Roles where the persona legitimately has no access (admin-only roles,
auth-provider roles) MUST declare an explicit env opt-out
`PERSONA_BIBER_BLOCKED=true` / `PERSONA_ADMINISTRATOR_BLOCKED=true`
in `templates/playwright.env.j2` with a documented rationale; without
the flag the helper fails the test with a diagnostic naming the
last-seen URL.
A new lint guard (`test_persona_strict_mode.py`) hard-fails any
future `test.skip(...)` in the persona helpers that is not guarded
by an allowed env opt-out / auth-less-collapse / service-gate
condition, and any `status === 200` branch in the prometheus / matomo
deny-check helpers that does not validate role-specific body markers.
Req 019 Rule 11 (strict-skip contract) and Rule 12 (200-body validation)
plus a matching "Strict failure on un-executable persona cases"
section in `playwright.specs.js.md` document the contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces roles/test-e2e-playwright/files/personas/ — runGuestFlow,
runBiberFlow, runAdminFlow plus 10 utils (csp, dashboard, env, keycloak,
landing, logout, matomo, prometheus, interaction, index) — and wires
both the Ansible runner (run_one.yml) and the local rerun helper
(scripts/tests/e2e/rerun-spec.sh) to stage the tree alongside
service-gating.js so specs can `require("./personas")`.
First batch of role specs migrated to consume the shared library
(dashboard, keycloak, mailu, matomo, prometheus, web-svc-cdn,
web-svc-simpleicons) — each gets the spec.js persona scenarios plus
the matching meta/services.yml + playwright.env.j2 entries; matomo is
marked role-closed in req 019. dashboard/keycloak/mailu TODO.md
updated; docs/requirements/019 reflects matomo closure.
Reason: req 019 Rule 3 mandates that every role's guest/biber/
administrator flow runs the same canonical steps; centralising them
in one shared library keeps per-role drift from re-introducing the
stub-shaped bodies the new lint forbids.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>