Skip to content

tests+infra: OIDC system-test coverage + drop .dev from local compose files#185

Merged
ExtraToast merged 6 commits into
mainfrom
tests-and-compose-rename
May 11, 2026
Merged

tests+infra: OIDC system-test coverage + drop .dev from local compose files#185
ExtraToast merged 6 commits into
mainfrom
tests-and-compose-rename

Conversation

@ExtraToast

Copy link
Copy Markdown
Contributor

Summary

Two related changes shipped together since they touched the same set of files:

1. Drop .dev from local docker-compose files (6486eecc)

docker compose up is now the canonical command. docker-compose.dev.yml and the four service-level docker-compose.dev.yml files lose the .dev suffix. The Swarm-era services/{api,frontend,listmonk}/docker-compose.yml variants (left over from before the k3s+Flux migration in #117) are removed — they aren't referenced by any current script, runbook, or CI workflow, and they would otherwise collide with the renamed files.

References updated:

  • dev-setup.sh, scripts/generate_openapi.sh
  • .github/workflows/ci.yml path filter
  • README.md development-setup section
  • Kdocs in BrevoContactAdapterLiveIT and ListmonkContactAdapterLiveIT
  • The codebase-pin comment in platform/cluster/flux/apps/data/mariadb/release.yaml

2. OIDC/SSO system-test coverage (fcb742f9)

Carries the OIDC test patterns proven in personal-stack into :services:system-tests as a new oidc/ package alongside the existing frontend/ Playwright suite. All run via the embedded @SpringBootTest on :8080 — no docker-compose dependency at CI time.

Test Covers
JwksDiscoverySystemTest /.well-known/oauth-authorization-server + /oauth2/jwks contract
AuthorizeRedirectSystemTest /oauth2/authorize for both headlamp + vault: anon → /login, member → 403 (admin-only client gate), admin → 302 with code=
OidcTokenClaimsSystemTest Full PKCE code-grant for headlamp; OidcTokenCustomizer claim shape (sub, aud, roles, username, email; groups → k8s-admin + member)
ForwardAuthChainSystemTest /oauth2/forward-auth parametrized across every host in HOST_ROLE; lifts ForwardAuthControllerIT from integration → system layer
OidcAuthorizePlaywrightTest Same authorize chain via Playwright's APIRequest (parametrized over both clients)

Plus OidcSystemTestBase (slim @SpringBootTest base with a no-follow HttpClient + bearer-token helpers) and OidcTestHelper (PKCE S256, JWT payload decode, form-encode, query-param parse).

Test plan

  • CI green on tests-and-compose-rename — all 4 system-test shards + api unit + integration
  • docker compose up boots the local stack with no -f flag
  • bash dev-setup.sh still produces working env files + TLS certs
  • bash scripts/generate_openapi.sh regenerates the spec against the renamed compose
  • ./gradlew :services:system-tests:test --tests "net.blueshell.api.system.oidc.*" — focused run on the new suite

…-era duplicates

- docker-compose.dev.yml -> docker-compose.yml (root manifest + api,
  frontend, stalwart, listmonk service includes). docker compose up
  is now the canonical command.
- Delete the unused services/{api,frontend,listmonk}/docker-compose.yml
  Swarm-era variants left over from before the k3s+Flux migration; the
  active dev compose now lives at the .dev-less name.
- Update references in dev-setup.sh, scripts/generate_openapi.sh,
  .github/workflows/ci.yml path filter, README.md, the live-IT
  Kdocs, and the mariadb release.yaml comment.
… forward-auth

Carries the OIDC test patterns proven in personal-stack into
:services:system-tests as a new oidc/ package alongside the existing
frontend/ Playwright suite. All run via the embedded @SpringBootTest
on :8080 — no docker-compose dependency at CI time.

New tests:

- JwksDiscoverySystemTest: /.well-known/oauth-authorization-server
  advertises authorize/token/jwks; /oauth2/jwks publishes RSA signing
  keys with kid; discovery jwks_uri path matches the in-process
  endpoint.
- AuthorizeRedirectSystemTest: parametrized over both registered
  clients (headlamp, vault). Anonymous → 302 to /login with the
  original /oauth2/authorize URL preserved as redirect=. Member
  → 403 from the downstream-client gating filter (the admin-only
  client guard in AuthorizationServerConfig). Admin → 302 to the
  configured redirect_uri with code= and state= round-tripped.
- OidcTokenClaimsSystemTest: full PKCE code-grant for headlamp.
  Exchanges code at /oauth2/token, decodes both ID + access tokens,
  asserts OidcTokenCustomizer's claim shape end-to-end (sub, aud,
  roles, username, email; ID-token groups maps ADMIN → k8s-admin +
  member). Vault grant omitted — its CLIENT_SECRET_BASIC config is
  empty in the test profile and out of scope here; vault authorize
  is still covered above.
- ForwardAuthChainSystemTest: lifts ForwardAuthControllerIT to the
  system-test layer over real HTTP. Parametrized across every host
  in HOST_ROLE (vault, headlamp, traefik = ADMIN; listmonk,
  stalwart = BOARD). Asserts anonymous → /login, member → 302
  /unauthorized?service=, admin → 200 + X-User-Id/X-User-Groups,
  board pass-through on board-gated hosts and 302 on admin-gated
  hosts, plus the unknown-host ADMIN fallback.
- OidcAuthorizePlaywrightTest: drives /oauth2/authorize through
  Playwright's APIRequest (matching personal-stack's
  RabbitMqOidcPlaywrightTest pattern). Parametrized over both
  clients. APIRequest rather than Page navigation because the
  configured redirect_uri is a production hostname — APIRequest
  stops at the 302, Page would resolve DNS and hang.

Plus OidcSystemTestBase (slim @SpringBootTest base with a
no-follow HttpClient and bearer-token helpers) and OidcTestHelper
(PKCE S256 + JWT payload decode + form-encode + queryParam).
@ExtraToast ExtraToast added the enhancement New feature or request label May 11, 2026
@ExtraToast ExtraToast self-assigned this May 11, 2026
…e principal

Under Spring Security 6's requireExplicitSave=true default, mutating
SecurityContextHolder isn't enough — downstream filters that resolve
the principal through the deferred supplier (OAuth2 SAS authorize, for
one) read the SecurityContextRepository, not the holder, and would
otherwise see an anonymous context.

Symptom: bearer-authenticated /oauth2/authorize returned 401 over real
HTTP. MockMvc never tripped it because it skips the deferred-supplier
round trip; ForwardAuthControllerIT consequently passed while every
new OIDC system test failed (run 25655557367, shards 2 + 4).

The fix is two beans:

- JwtAuthFilter now takes a SecurityContextRepository and calls
  saveContext after authenticating, so the in-flight ThreadLocal is
  also written through to the per-request attribute the deferred
  supplier reads from.
- A new SecurityContextRepositoryConfig publishes the repository
  (DelegatingSecurityContextRepository wrapping
  RequestAttributeSecurityContextRepository + HttpSession one — the
  Spring Security 6 default shape) and a FilterRegistrationBean that
  disables Spring Boot's auto-registration of @component JwtAuthFilter
  as a raw servlet filter outside the security chain.

The config lives outside SecurityConfig.kt to avoid a circular
dependency: SecurityConfig already takes JwtAuthFilter via the
constructor, so it can't also be the producer of a bean JwtAuthFilter
depends on.

Local: :services:api:integrationTest --tests "*ForwardAuthControllerIT*"
green (7/7). System tests deferred to CI — host :8080 is busy with an
unrelated container on this machine.
… Bearer

The actual fix for the 401s on /oauth2/authorize. Root cause was
.oidc(Customizer.withDefaults()) in AuthorizationServerConfig, which
auto-enables an OidcUserInfoEndpointConfigurer. That implicitly wires
oauth2ResourceServer.jwt() onto the SAS chain, which both:

  - adds a BearerTokenAuthenticationFilter that intercepts every
    Authorization: Bearer request to /oauth2/* and validates the JWT
    against the SAS RSA JWKS — our HS256 session tokens fail, and the
    filter responds 401 directly via its own BearerTokenAuthentication-
    EntryPoint.
  - registers that same 401 entry point as the default for any request
    matching Accept: */*, X-Requested-With, or REST content types —
    shadowing our loginRedirectEntryPoint for anonymous requests.

Production isn't affected: real browsers send the BSH_AUTH cookie (not
Authorization: Bearer) and Accept: text/html with a quality on */*, so
neither shortcut fires. Tests just need to mirror that.

OidcSystemTestBase.get now sends `Cookie: BSH_AUTH=<jwt>` (matching
AuthTokenCookieService's default cookie name, overridable via
security.auth-cookie.name) and Accept: text/html on every request.
sessionTokenFor replaces bearerToken; the parameter is renamed
sessionToken everywhere so the call sites are obvious. The Playwright
authorize test does the same via RequestOptions headers.

The token-exchange POST in OidcTokenClaimsSystemTest is unchanged: it
hits /oauth2/token without an Authorization header (PKCE), which never
trips the bearer auth filter.
Two remaining test-side issues after the cookie-auth fix landed:

- Jackson 3.1 introduced `JsonNode.map(Function)` as an Optional-style
  (apply-to-whole-node) operator. It shadows Kotlin's `Iterable.map`
  when chained on a JsonNode, so `arrayNode.map { it.asString() }` ran
  the lambda on the entire ArrayNode rather than its elements and blew
  up with a coercion error. Two new OidcTestHelper helpers iterate via
  the explicit `iterator().asSequence()` path; tests use those.
- Tomcat resolves the relative `/login?redirect=...` path returned by
  loginRedirectEntryPoint to an absolute URL before writing the
  Location header, so `assertThat(location).startsWith("/login?...")`
  failed even though the redirect itself was correct. Loosened to
  `contains(...)` on the same anchor.
JWT `aud` is serialized as a bare string when there's a single
audience (Nimbus + RFC 7519 default). The array-only iteration
returned an empty list, breaking the access-token aud assertion in
OidcTokenClaimsSystemTest. Branch on isArray and fall through to the
text-node case so the helper works for both shapes.
@ExtraToast ExtraToast marked this pull request as ready for review May 11, 2026 09:21
@ExtraToast ExtraToast merged commit 725fb5c into main May 11, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant