Files
cc-ci/tests/keycloak/functional/test_password_grant_token.py
autonomic-bot d5f5e86c7b feat(2): Q2.1 — keycloak Phase-2 parity + functional (full e2e green)
- tests/keycloak/PARITY.md: parity table (health_check ported); oidc_integration.py
  noted as Q3-deferred (cross-recipe test needs lasuite-docs + dep resolver).
- tests/keycloak/functional/test_health_check.py: parity port of
  recipe-info/keycloak/tests/health_check.py — SOURCE comment.
- tests/keycloak/functional/test_password_grant_token.py: NEW recipe-specific —
  password grant against /realms/master/protocol/openid-connect/token; decodes
  the JWT payload; asserts iss=https://<live_app>/realms/master, azp=admin-cli,
  typ=Bearer, exp in future, iat reasonable past. Reuses kc_admin.py helpers.
- tests/keycloak/functional/test_create_client_and_use.py: NEW recipe-specific —
  admin creates a UUID-named confidential client via admin API → uses client
  credentials grant to obtain a service-account token → decodes JWT, asserts azp
  matches the new clientId, iss matches per-run domain → idempotent DELETE cleanup.
- tests/keycloak/recipe_meta.py: bumped DEPLOY_TIMEOUT + HTTP_TIMEOUT 600 -> 900
  (cold-start JVM + mariadb migration intermittently exceeds 600s on a 2-vCPU host;
  observed 502 fallback after 600s in run #1).

Cold-verifiable on cc-ci (log /root/ccci-q2-keycloak-r3.log):
  RECIPE=keycloak cc-ci-run runner/run_recipe_ci.py
  all 5 stages PASS, deploy-count=1, head_ref=666649a6==chaos-version=666649a6
  (HC1 non-vacuous), version 10.7.0+26.6.1 -> 10.7.1+26.6.2.
  Custom tier 3 PASS: parity health_check, JWT password-grant, client_credentials.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:34:14 +01:00

76 lines
3.1 KiB
Python

"""keycloak — recipe-specific functional test (Phase 2 P3, ≥2 beyond parity).
The defining keycloak behavior is issuing JWTs. This test does the canonical password-grant flow
against the live keycloak (real admin user, real abra-generated admin_password secret), then
decodes the returned JWT and asserts the standard claims: `iss` (issuer URL), `azp` (authorized
party), `typ == "Bearer"`, and `exp` in the future. Non-vacuous: a wrongly-configured realm,
broken signing key, or wrong issuer would fail the claim assertions, not just status==200.
Reuses the per-recipe `tests/keycloak/kc_admin.py` helpers (admin_password + admin_token shape)
and the harness `lifecycle.exec_in_app` (already polled+raising per Phase 1e F1e-1 fix).
"""
from __future__ import annotations
import base64
import json
import os
import sys
import time
# kc_admin.py lives in tests/keycloak/, one level up from this file in tests/keycloak/functional/
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
import kc_admin # noqa: E402
def _b64url_decode(seg: str) -> bytes:
"""JWT segments are URL-safe base64 without padding — add padding before decode."""
pad = "=" * ((4 - len(seg) % 4) % 4)
return base64.urlsafe_b64decode(seg + pad)
def _decode_jwt_payload(token: str) -> dict:
parts = token.split(".")
assert len(parts) == 3, f"JWT must have 3 segments (header.payload.signature), got {len(parts)}"
payload = json.loads(_b64url_decode(parts[1]))
return payload
def test_password_grant_issues_valid_jwt(live_app):
"""Obtain a token via the password grant, validate the JWT shape + standard claims."""
password = kc_admin.admin_password(live_app)
token = kc_admin.admin_token(live_app, password)
# Shape: a JWT is exactly 3 base64url segments
assert isinstance(token, str) and token.count(".") == 2, (
f"access_token does not look like a JWT (no 3 segments): len={len(token) if token else 0}"
)
payload = _decode_jwt_payload(token)
# iss = the issuer URL, must be the per-run domain's /realms/master endpoint
expected_iss = f"https://{live_app}/realms/master"
assert payload.get("iss") == expected_iss, (
f"JWT iss claim {payload.get('iss')!r} != {expected_iss!r}"
)
# azp = authorized party (which client requested this token)
assert payload.get("azp") == "admin-cli", (
f"JWT azp claim {payload.get('azp')!r} != 'admin-cli'"
)
# typ = token type
assert payload.get("typ") == "Bearer", f"JWT typ claim {payload.get('typ')!r} != 'Bearer'"
# exp must be in the future (the token must be usable, not pre-expired)
exp = payload.get("exp")
assert isinstance(exp, int), f"JWT exp claim missing or not int: {exp!r}"
assert exp > time.time(), f"JWT exp {exp} not in the future (now={time.time():.0f})"
# iat (issued at) is also a standard claim
iat = payload.get("iat")
assert isinstance(iat, int) and iat <= time.time() + 60, (
f"JWT iat {iat!r} not a reasonable past timestamp"
)