The negative control aborted every <host>/logout and then asserted the
admin bearerAuth cookie survives. It failed in run 32597195876, job
Shopware#0, in 5 of 6 executions: the cookie was gone anyway.
The premise was right and the target was wrong. The trace of the failing
run shows all nine sweep aborts landing - 584 ms too late:
51.534 -1 shop/api/oauth/sso/config logoutSso() cut off by the
injected logout redirect
51.538 200 auth/.../openid-connect/logout
52.842 200 auth/.../logout/logout-confirm
53.377 200 logout.<domain>/?sid=... conductor, Clear-Site-Data
53.961 -1 9x <host>/logout sweep, correctly blocked
54.256 200 shop/admin cookie already gone
What deletes the cookie is the Clear-Site-Data header on web-svc-logout's
conductor page - "the ONLY path-agnostic cookie deletion mechanism" per
universal-logout's app.py - and it clears the whole registrable domain,
which is how an origin on logout.<domain> reaches a cookie on
shop.<domain>. The per-app /logout requests are the second mechanism, not
the first. Shopware's own clearAuthState never runs either: the injected
redirect preempts it.
The route now blocks the conductor, which is the honest simulation of
"web-svc-logout never lands". It loads after Keycloak's logout-confirm,
so blocking it cannot disturb that flow.
Two anchors identify it, because one is not enough on every axis.
`lookup('tls', ...)` family-aligns a cross-app reference per consumer
(utils/tls_common.py align_domain_to_consumer), so where web-svc-logout
is onion-primary and this role and Keycloak sit in different families,
the origin resolved here is not the one Keycloak framed. The front-channel
parameters Keycloak appends catch that case. LOGOUT_URL stays optional for
the same reason - an empty lookup must fall through to the second anchor
rather than fail the test.
The sibling's poll message names the real mechanism and keeps the
rationale for its 30s wait, which 5b6d12f12 added for a reproduced
Tor-only failure.
Verified on clearnet evidence only: the failing job skipped every Tor
task, and no Tor run in either pipeline produced a trace, since traces are
retain-on-failure and those jobs were green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ca40eee68 made the admin test wait for bearerAuth to disappear before leaving
Keycloak's logout page. Nothing proves that wait is load-bearing. Delete it and
the suite stays green on clearnet, because there the sweep wins the race
anyway; it turns red only over Tor, where a red job reads as flake. The
property the wait protects is therefore untested exactly where it matters.
Racing it deliberately cannot fix that: a test that leaves early and asserts
the session survived would pass over Tor and fail on clearnet - the same
latency dependency, mirrored. 801923eb3 measured that asymmetry on Funkwhale:
"Only swarm+tor is slow enough to lose the race."
So this does not race. page.route aborts every request whose pathname is
/logout - each app host the conductor sweeps - and the test then asserts the
admin cookie SURVIVES. What is pinned is the causal link, not a timing: the
session outlives a logout exactly when the sweep does not land. That stays
true however fast the sweep becomes, and breaks only if logout stops depending
on it, which is a change worth failing on.
The pathname filter is deliberate. A glob on **/logout* would also swallow
Keycloak's own /realms/x/protocol/openid-connect/logout and break the flow
under test; url.pathname === "/logout" matches the sweep targets and neither
Keycloak's end-session endpoint nor the conductor page at /.
Two ways this test could pass while proving nothing, both closed. A parallel
page.on("request") counter separates them: no request to /logout at all means
the conductor never reached its sweep, the precondition is absent and the test
skips - making the sweep land is the sibling test's assertion. Requests seen
but nothing intercepted means the abort is a no-op, which fails loudly rather
than skipping forever if page.route ever stops matching.
Skipping rather than failing on the absent precondition also keeps the cost
bounded: playwright.config.js runs workers: 1 with retries: 2, so a failure
here would spend three full Tor login cycles proving something the sibling
already reports.
Interception was measured, not assumed: with three origins, a cross-origin
iframe and keepalive: true, the handler fires and the target server sees no
request. signIn/dismissFirstRunWizard/clickLogout are extracted so the second
test costs helpers rather than a copy of the first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e59ea21c6 waited on document.cookie, which is origin-scoped. By the time
it runs the page sits on logout.<domain>, where Shopware's bearerAuth -
host-scoped to shop.<domain> - is not visible. The predicate was true on
the first poll, so the wait did nothing and three of the six jobs were
unchanged.
page.context().cookies() reads the browser context's jar regardless of
which document is displayed, so the same post-condition can be observed
from Keycloak's logout page.
The wait matters because the thing that clears that cookie is not
Shopware and not the injected patch - it is web-svc-logout. Keycloak
loads its conductor as the front-channel logout URL in an iframe; the
conductor fetches <host>/logout for every domain with
credentials: "include" (universal-logout templates/conductor.html.j2),
each /logout deletes the cookies visible on that host and sends
Clear-Site-Data (app.py), and roles/web-svc-logout/templates/logout-proxy.conf.j2
supplies the matching Access-Control-Allow-Origin plus
Allow-Credentials. Every link of that chain is in place.
What was missing was time. In run 32284541284 the trace shows the
logout-confirm click at 16543.805 and the navigation to /admin at
16902.227 - 358 ms, against 220-350 ms per Tor hop. The conductor's
document had not finished loading, so its sweep never ran:
grep -c -a "onion/logout" on the trace network log returns 0. On clearnet
the same chain fits inside those 358 ms, which is why variant 0 passes
there and fails over Tor.
A failure now reports the cookie that survived instead of the missing
password field two lines below, which said nothing about the cause.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4a543b3e1 moved the assertion back onto /admin so a surviving session
would fail where it means something. It does fail there - and the reason
is that nothing waits for the logout to finish before the page is torn
down.
waitForLoadState only observes a navigation that is already committed; it
never waits for one a click is about to start. Shopware's SPA logout
triggers no document load at all, so the call returned in 0.7ms and the
gotoOnion below fired 3.4ms after the click, killing the in-flight
teardown. Measured in run 32284541284 job 96178512465's trace: click at
10363.718, waitForLoadState returns 10420.992, goto starts 10422.365.
Proof the teardown never completed: POST /api/oauth/token with
grant_type=refresh_token returned 200 five minutes later, and
_action/user/logout appears zero times in the network log (positive
control _action: 6).
Waiting on the credential instead of on a load states the real
precondition, and it is faster on the happy path than any fixed settle
would be - it ends the moment the cookie goes. The catch keeps a timeout
from masking the assertion below it with a different error.
Six jobs across both runs, and the grid rules out the two obvious
culprits: variant 2 carries no injected logout script and still fails,
and variant 2 swarm+clearnet fails while variant 2 swarm+tor passes. It
is a race, not a tor defect and not an injector defect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The native admin spec clicked in-app logout and then waited 60s for a password field on whatever page it had landed on. That page is never Shopware: with web-svc-logout attached, the flow ends on logout.<domain>, the universal-logout status page, which is terminal by design (README: "Front Channel Logout URL ... status page with live feedback").
Evidence from job 95883075100: the error-context snapshot shows heading "Logging out" plus paragraph "You are logging out from following apps" and a list - the web-svc-logout UI, not Keycloak's end-session page, which carries a Logout button instead (that one was already handled by a30d67a63). The openresty access log confirms the chain at 03:38:58: POST /realms/.../logout/logout-confirm 200, then GET logout.infinito.example/?sid=...&iss=... 200, 2555 bytes.
Returning to ${appBaseUrl}/admin before the expectation puts the claim where it can be true or false. This is the same move c7e5b0a27 made for web-app-funkwhale, and the shared persona flow has always done it via assertUnauthenticatedLanding (personas/utils/landing.js). The assertion itself is untouched, so the test gets stricter, not more lenient: a session that survived logout now fails here instead of at a spot that said nothing about it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Shopware's in-app logout leaves the browser on Keycloak's end-session page, which asks 'Do you want to log out?' because the request carries no id_token_hint. The spec waited 60s for a password field that page never renders. The shared persona helper already handles this for inAppLogout; it is now exported so the role-local spec can use it too.
Evidence: the error-context of job 95119330653 shows heading "Logging out" and button "Logout" where the login form was expected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
meta/services.yml pinned sso.flavor: oauth2, so an oauth2-proxy fronted the whole role. That gated the storefront too and left /admin behind a proxy the administrator persona could not log out of, while tasks/setup/oidc.yml -- the only OIDC wiring -- installed a literal placeholder plugin name behind an || true and wrote InfxOidc.config.* keys that no plugin reads. tasks/03_setup.yml and tasks/04_cleanup.yml were included by nothing, so none of it ever ran.
The flavor moves to oidc, which drops the proxy on its own because sso_proxy.yml keys off is_proxy_gated: the storefront becomes public and /admin authenticates through heptacom/shopware-platform-admin-open-auth against Keycloak. The plugin is baked into files/Dockerfile beside the existing composer require, installed and activated by tasks/setup/oidc.yml, and its Keycloak client row is written idempotently by files/ensure/oidc_client.php over the Admin API -- GET by provider, then PATCH or POST -- because the plugin keeps its configuration in the heptacom_admin_open_auth_client DAL entity, not in system_config.
The web service gains container_extra_hosts. With the proxy gone the token exchange runs inside the shopware container, which under swarm resolves clearnet only and could not reach the keycloak onion.
The LDAP plugin tasks are deleted rather than wired. No admin-LDAP extension for 6.7 is installable unattended -- the only real one is a paid Store extension needing a licence token -- and arming tasks/03_setup.yml would have fired the INFX_LDAP_PLUGIN placeholder on every deploy that ships OpenLDAP, since meta/services.yml derives ldap.enabled from group membership. Directory identities reach the backend through the Keycloak LDAP federation instead.
PERSONA_BIBER_BLOCKED becomes unconditional: biber has an account in no variant. test-admin-native.js keeps the authenticated journey and now drives the AdminOpenAuth provider link when SSO is on.
Verified by execution: the provisioning payload and both idempotency branches against a stub; composer resolving 9.1.0 against shopware/core 6.7.13.0 without conflict; the install gate rejecting a no-plugins-found run and accepting both success shapes.
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>
The shared persona helper cannot log the administrator out of Shopware. The only logout control lives in .sw-admin-menu__user-actions, which the administration stylesheet keeps at display:none until its toggle is clicked, and that toggle is a bare div carrying no role, no aria-haspopup and no dropdown class, so nothing in the helper's trigger set matches it. Its second fallback, following a same-origin settings link, misses as well because the administration is hash-routed and every settings target is #/sw/settings/...
Declare PERSONA_ADMINISTRATOR_BLOCKED and move the journey into a role-local spec that drives Shopware's own selectors: native admin login, an authenticated backend assertion, logout through the user-actions toggle, and back to the login form. It skips when SSO_SERVICE_ENABLED=true, because the oauth2-proxy then owns /admin and the native form is unreachable.
The spec leaves the first-run wizard by navigating away from its route rather than closing it: the wizard renders with :closable=false on its first step, so it has neither a close button nor an escape handler, while its footer intercepts the pointer events aimed at the admin menu behind it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>