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:
85
tests/lasuite-docs/functional/test_oidc_with_keycloak.py
Normal file
85
tests/lasuite-docs/functional/test_oidc_with_keycloak.py
Normal 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})"
|
||||
)
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user