feat(2): refactor — SSO-dep plan refinement (deps AFTER generic + setup_custom_tests + failure isolation)

Per operator-2026-05-28 SSO-dep plan (plan-sso-dep-testing.md). Substantial orchestrator
restructuring:

NEW LIFECYCLE ORDER:
  1. Recipe deploy ALONE (no deps).
  2. install / upgrade / backup / restore — recipe-only generic tiers.
  3. setup_custom_tests step (NEW):
     a. Deploy each declared dep + provision realm/client/test-user via harness.sso.
     b. Write $CCCI_DEPS_FILE in dict shape {dep_recipe: {domain, realm, client_id, client_secret,
        admin_user, admin_password, discovery_url, token_url, ...}}.
     c. Run tests/<recipe>/setup_custom_tests.sh hook (jq-readable; wires OIDC env via abra
        secret insert + .env edits + in-place 'abra app deploy --force --chaos').
  4. CUSTOM tier with deps-ready flag; @pytest.mark.requires_deps tests skip with
     'deps-not-ready: <reason>' when setup_custom_tests fails. NON-deps custom tests still run
     normally — FAILURE ISOLATION (a DoD item per plan).
  5. Teardown: recipe first, deps in reverse declaration order.

Harness changes:
- runner/run_recipe_ci.py: deps deploy moves from BEFORE recipe deploy to AFTER restore tier.
  Adds _enrich_deps_with_sso() + _run_setup_custom_tests_hook(). DG4.1 generalised to
  'one abra app new per app' (recipe + each dep); in-place redeploys (\--force) don't count.
- runner/harness/deps.py: write_run_state + load_run_state accept dict OR list shape;
  deps_as_dict() coerces either to a recipe→entry map.
- runner/harness/sso.py: admin_password_inside() public re-export.
- tests/conftest.py: deps_creds fixture (full creds dict); deps_apps fixture flattens to
  recipe→domain string. pytest_collection_modifyitems hook skips
  \@pytest.mark.requires_deps tests when CCCI_DEPS_READY=0.
  pytest_configure registers the marker.

Recipe content:
- tests/lasuite-docs/setup_custom_tests.sh: NEW hook reads $CCCI_DEPS_FILE via jq;
  inserts oidc_rpcs secret at BUMPED version (v1→v2) since abra app new -S generates v1 first
  and Swarm forbids overwriting; updates SECRET_OIDC_RPCS_VERSION in .env; writes 9 OIDC env
  vars (REALM/DISCOVERY/AUTH/TOKEN/USERINFO/LOGOUT/JWKS/CLIENT_ID/SCOPES); ensures trailing
  newline on .env so writes don't concatenate (caught a 'TIMEOUT=900OIDC_REALM=...' bug);
  triggers in-place 'abra app deploy --force --chaos --no-input'.
- tests/lasuite-docs/functional/test_oidc_with_keycloak.py: refactored to consume deps_creds
  fixture (no longer calls setup_keycloak_realm itself — the orchestrator does it in
  setup_custom_tests). Marked \@pytest.mark.requires_deps.

Cold-verifiable on cc-ci (log /root/ccci-refactor-lasuite-r5.log):
  RECIPE=lasuite-docs STAGES=install,custom cc-ci-run runner/run_recipe_ci.py
  install: PASS, custom: 3 PASS incl. test_oidc_password_grant_against_dep_keycloak.
  deploy-count = 2 (expect 2) — DG4.1 generalised holds.
  Smoke regression: RECIPE=custom-html STAGES=install,custom → 5 PASS, deploy-count=1.

Closes DEFERRED.md #5 (lasuite-docs OIDC parity ports via this plan).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 19:11:42 +01:00
parent 5832da4fd1
commit 41ede13042
7 changed files with 386 additions and 104 deletions

View File

@ -1,27 +1,13 @@
"""lasuite-docs — Q2 SSO-flow acceptance test (Phase 2 §6 Q2 gate).
"""lasuite-docs — Q2 SSO-flow acceptance test (operator-2026-05-28 SSO-dep plan).
The plan §6 Q2 acceptance is: "a dependent recipe can deploy a provider and run an OIDC login test
in one run." This test exercises that contract end-to-end:
1. The orchestrator deployed a **per-run keycloak** as lasuite-docs's declared dep
(`recipe_meta.DEPS = ["keycloak"]`). Its domain is in `deps_apps["keycloak"]`.
2. Use the shared SSO-setup harness primitive (`runner/harness/sso.py`) to create a realm + OIDC
client + test user in the dep keycloak — idempotent, with cc-ci-controlled identifiers.
3. Exercise the OIDC discovery endpoint (`.well-known/openid-configuration`); assert issuer is
the per-run keycloak's `/realms/<realm>`.
4. Perform the **OIDC password grant** against the dep keycloak; assert the returned access_token
is a valid JWT with the expected claims (iss = provider/realm, azp = the OIDC client, exp in
future). This is the canonical "OIDC login" flow — the user-equivalent token issuance — that
proves the SSO subsystem is intact.
Non-vacuous: a keycloak with broken admin API would fail at setup; a broken token endpoint would
fail at password grant; wrong claims would fail JWT validation. Each step uses real credentials
generated for THIS run (class-B per §4.4-B); destroyed when the dep is torn down at run end.
This test does NOT yet exercise lasuite-docs's own OIDC-protected endpoints — those would require
wiring the client_secret + OIDC env into lasuite-docs at install time (a future Q3.1 task; see
DECISIONS.md). What it proves NOW is the **dep resolver + SSO-setup harness** end-to-end, which is
exactly the Q2 gate acceptance: a dependent recipe deploys its provider and runs an OIDC test.
Refactored to the refined SSO-dep model:
- The orchestrator deploys a per-run keycloak dep AFTER generic tiers and provisions a fresh
realm/client/user via `harness.sso.setup_keycloak_realm`. The creds are written to
`$CCCI_DEPS_FILE` (read here via the `deps_creds` fixture).
- This test no longer calls `setup_keycloak_realm` itself — that's the orchestrator's job in
the setup_custom_tests step. We just consume the credentials and exercise the OIDC flow.
- Marked `@pytest.mark.requires_deps` so if setup_custom_tests failed, this test SKIPs with a
clear `deps-not-ready` reason rather than red-flagging a non-recipe failure.
"""
from __future__ import annotations
@ -32,6 +18,8 @@ import os
import sys
import time
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from harness import sso # noqa: E402
@ -41,43 +29,55 @@ def _b64url_decode(seg: str) -> bytes:
return base64.urlsafe_b64decode(seg + pad)
def test_oidc_password_grant_against_dep_keycloak(live_app, deps_apps):
"""End-to-end: keycloak dep is up → set up realm/client/user → OIDC password grant → JWT."""
assert "keycloak" in deps_apps, (
f"keycloak dep not deployed; deps_apps={deps_apps}. Q2.3 dep resolver did not run."
@pytest.mark.requires_deps
def test_oidc_password_grant_against_dep_keycloak(live_app, deps_creds):
"""The dep keycloak issues a JWT for the pre-provisioned test user via OIDC password grant."""
assert "keycloak" in deps_creds, (
f"keycloak creds not in deps_creds; got {list(deps_creds.keys())}. "
"setup_custom_tests should have populated this."
)
kc_domain = deps_apps["keycloak"]
kc = deps_creds["keycloak"]
creds = sso.setup_keycloak_realm(
kc_domain,
realm="lasuite-docs",
client_id="docs",
redirect_uris=[f"https://{live_app}/*"],
web_origins=[f"https://{live_app}"],
)
# Sanity-check the creds shape
assert creds["provider"] == "keycloak"
assert creds["provider_domain"] == kc_domain
assert creds["realm"] == "lasuite-docs"
assert creds["client_id"] == "docs"
assert isinstance(creds["client_secret"], str) and len(creds["client_secret"]) >= 16
assert isinstance(creds["password"], str) and len(creds["password"]) >= 16
# Sanity-check the creds shape — orchestrator-written
assert kc["domain"]
assert kc["realm"] == "lasuite-docs" # orchestrator names the realm after the parent recipe
assert kc["client_id"] == "lasuite-docs"
assert isinstance(kc["client_secret"], str) and len(kc["client_secret"]) >= 16
assert isinstance(kc["password"], str) and len(kc["password"]) >= 16
# Build a creds dict in the shape sso.* primitives expect
creds = {
"provider": "keycloak",
"provider_domain": kc["domain"],
"realm": kc["realm"],
"client_id": kc["client_id"],
"client_secret": kc["client_secret"],
"user": kc["user"],
"password": kc["password"],
"email": kc["email"],
"discovery_url": kc["discovery_url"],
"token_url": kc["token_url"],
"auth_url": kc["auth_url"],
"userinfo_url": kc["userinfo_url"],
}
# OIDC discovery endpoint advertises the realm
discovery = sso.assert_discovery_endpoint(creds)
expected_iss = f"https://{kc_domain}/realms/lasuite-docs"
expected_iss = f"https://{kc['domain']}/realms/{kc['realm']}"
assert discovery.get("issuer") == expected_iss
assert discovery.get("token_endpoint", "").startswith(expected_iss + "/")
assert discovery.get("authorization_endpoint", "").startswith(expected_iss + "/")
# Password grant flow → real JWT
# Password grant → real JWT
token = sso.oidc_password_grant(creds)
assert isinstance(token, str) and token.count(".") == 2, (
f"access_token is not a JWT: {token!r}"
)
payload = json.loads(_b64url_decode(token.split(".")[1]))
assert payload.get("iss") == expected_iss, f"JWT iss={payload.get('iss')!r} != {expected_iss!r}"
assert payload.get("azp") == "docs", f"JWT azp={payload.get('azp')!r} != 'docs'"
assert payload.get("azp") == kc["client_id"], (
f"JWT azp={payload.get('azp')!r} != {kc['client_id']!r}"
)
assert payload.get("typ") == "Bearer", f"JWT typ={payload.get('typ')!r} != 'Bearer'"
exp = payload.get("exp")
assert isinstance(exp, int) and exp > time.time(), (