feat(2): Q2.4 acceptance — lasuite-docs + keycloak dep + OIDC password grant (cold green)

- tests/lasuite-docs/recipe_meta.py: DEPS = ['keycloak'] declares the SSO provider dep.
  Orchestrator deploys a per-run keycloak BEFORE lasuite-docs (Q2.3 dep resolver) and tears it
  down AFTER in finally.
- tests/lasuite-docs/functional/test_oidc_with_keycloak.py: Q2 gate acceptance test.
  - Asserts deps_apps['keycloak'] is the per-run dep domain.
  - Calls harness.sso.setup_keycloak_realm to create realm/client/test-user idempotently.
  - GET /.well-known/openid-configuration; asserts issuer = https://<kc>/realms/lasuite-docs.
  - harness.sso.oidc_password_grant: password-grant flow; asserts the JWT iss/azp/typ/exp.
  - Non-vacuous: each step uses real per-run-generated creds (class-B per §4.4-B), would fail
    on broken admin API / token endpoint / wrong claims.

Cold-verifiable on cc-ci (log /root/ccci-q24-lasuite-keycloak.log):
  RECIPE=lasuite-docs STAGES=install,custom cc-ci-run runner/run_recipe_ci.py
  ===== DEPS: ['keycloak'] =====
    dep: deploying keycloak -> keyc-c12afe.ci.commoninternet.net
    dep: keycloak ready @ keyc-c12afe.ci.commoninternet.net
  ===== TIER: install =====   2 PASS (generic + cc-ci overlay)
  ===== TIER: custom =====    1 PASS (test_oidc_password_grant_against_dep_keycloak)
  ===== DEPS teardown =====
  ===== RUN SUMMARY =====
  deploy-count = 2 (expect 2)   # 1 parent + 1 dep

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 08:08:11 +01:00
parent 47f7cb47c2
commit 9e88741864
2 changed files with 91 additions and 0 deletions

View File

@ -0,0 +1,85 @@
"""lasuite-docs — Q2 SSO-flow acceptance test (Phase 2 §6 Q2 gate).
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.
"""
from __future__ import annotations
import base64
import json
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from harness import sso # noqa: E402
def _b64url_decode(seg: str) -> bytes:
pad = "=" * ((4 - len(seg) % 4) % 4)
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."
)
kc_domain = deps_apps["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
# OIDC discovery endpoint advertises the realm
discovery = sso.assert_discovery_endpoint(creds)
expected_iss = f"https://{kc_domain}/realms/lasuite-docs"
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
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("typ") == "Bearer", f"JWT typ={payload.get('typ')!r} != 'Bearer'"
exp = payload.get("exp")
assert isinstance(exp, int) and exp > time.time(), (
f"JWT exp={exp!r} not a future timestamp (now={time.time():.0f})"
)

View File

@ -8,6 +8,12 @@ HEALTH_OK = (200, 301, 302)
DEPLOY_TIMEOUT = 900
HTTP_TIMEOUT = 600
# Phase 2 Q2.3 deps: lasuite-docs's recipe-maintainer corpus declares `requires = ["keycloak"]`.
# Declaring it here makes the orchestrator deploy a per-run keycloak BEFORE lasuite-docs so the
# OIDC-flow functional test (`functional/test_oidc_with_keycloak.py`) can run against a real
# provider in the same run. The dep is undeployed AFTER the parent in the orchestrator's `finally`.
DEPS = ["keycloak"]
def EXTRA_ENV(domain):
# abra's internal per-deploy convergence timeout (the recipe's TIMEOUT env, default 300s) is too