litellm/tests/e2e/idp.py
ryan-crabbe-berri d0e37d39c4 Record each e2e test's steps from the harness it calls
A test's JUnit report says whether it passed, never what it did or where a failing test died. This records that from the harness, so nothing about it is hand-written and it cannot drift from what the test actually ran

`@step("create team with a budget")` from the new tests/e2e/e2e_metadata.py goes on harness helpers, never on tests, and appends its label to the running test's step log in call order. The label is recorded before the wrapped call, so a helper that raises still leaves its own label last: a failing test's last step is where it died. Every public harness method that performs an action now carries one, 355 across the client modules, lifecycle, idp, the logging readers, migrations and the claude_code driver

Only the outermost step records, tracked per thread. Harness layers call each other (ResourceManager.key goes through ProxyClient.generate_key, a domain client wraps the shared ProxyClient), so every layer carries a label and the story still reads at the level the test called in at, one beat per action. A step above @contextmanager holds the guard through __enter__ and __exit__, so a context's cleanup never lands behind the step a test died on, and a bare generator function is refused at import because its body interleaves with its caller's. Consecutive duplicates collapse and the log caps at 50, so a poll loop is one beat rather than fifty. The wrapper is a frame, so the eight cleanup and retry warnings raised directly inside decorated helpers use stacklevel=2 + STEP_FRAMES to keep reporting at their caller

The log is emptied first thing in pytest_runtest_setup and attached from the existing pytest_runtest_makereport wrapper after setup and again after call, so a test that errors in a fixture keeps the steps recorded before the crash. Teardown does not attach: finalizer steps are cleanup. Each attach drops the item's earlier step entries, so the second attach and a --reruns 1 retry replace the story rather than doubling it

Steps ride out as repeated <property name="step"> entries behind the fixed package/covers/source prefix, which stays byte-identical. The project-releaser emitter already regroups them into the results JSON's steps array. test_junit_report.py runs real pytest with --junitxml against this conftest, in-process and under -n 2, and pins the passing, failing, setup-error, rerun and wide-scope-fixture cases on the parsed XML
2026-09-21 18:48:59 -07:00

501 lines
18 KiB
Python

"""Provision isolated identities and obtain signed tokens from the test Keycloak realm."""
from __future__ import annotations
import base64
import os
import secrets
import signal
import subprocess
import sys
import time
import warnings
from collections.abc import Callable
from contextlib import ExitStack
from dataclasses import dataclass, field, replace
from types import FrameType
from typing import Final, Literal
import pytest
from e2e_http import (
AuthHeaders,
ExternalWrite,
NetworkError,
NoBody,
Result,
Success,
UnknownApiError,
delete_external,
get_external,
post_form_external,
post_json_external,
unwrap,
)
from e2e_metadata import step
from pydantic import BaseModel, Field
KEYCLOAK_URL_ENV: Final = "E2E_KEYCLOAK_URL"
KEYCLOAK_REALM_ENV: Final = "E2E_KEYCLOAK_REALM"
KEYCLOAK_ADMIN_USER_ENV: Final = "E2E_KEYCLOAK_ADMIN_USER"
KEYCLOAK_ADMIN_PASSWORD_ENV: Final = "E2E_KEYCLOAK_ADMIN_PASSWORD"
DEFAULT_KEYCLOAK_URL: Final = "http://127.0.0.1:8480"
DEFAULT_REALM: Final = "litellm-e2e"
TESTS_CLIENT_ID: Final = "litellm-e2e-tests"
SHORT_LIVED_CLIENT_ID: Final = "litellm-e2e-shortlived"
ADMIN_CLIENT_ID: Final = "litellm-e2e-admin"
WRONG_AUDIENCE_CLIENT_ID: Final = "litellm-e2e-other-app"
_START_HINT: Final = (
"Start it with the `docker run ... quay.io/keycloak/keycloak` command in tests/e2e/CONTRIBUTING.md, "
f"and point {KEYCLOAK_URL_ENV} / {KEYCLOAK_ADMIN_USER_ENV} / {KEYCLOAK_ADMIN_PASSWORD_ENV} at it"
)
class TokenGrantForm(BaseModel):
"""The direct-access (password) grant an OAuth 2 token endpoint takes, form encoded."""
grant_type: Literal["password"] = "password"
client_id: str
username: str
password: str = Field(repr=False)
client_secret: str | None = Field(default=None, repr=False)
scope: str | None = None
class TokenResponse(BaseModel):
access_token: str = Field(repr=False)
class TokenRequestHeaders(BaseModel):
host: str | None = None
class GroupCreateBody(BaseModel):
name: str
class PasswordCredential(BaseModel):
type: Literal["password"] = "password"
value: str = Field(repr=False)
temporary: bool = False
class UserCreateBody(BaseModel):
"""Keycloak's admin representation of a new user. `firstName` / `lastName` and
an empty `requiredActions` matter: a realm's default VERIFY_PROFILE action
otherwise leaves the account "not fully set up" and every grant fails."""
username: str
email: str
email_verified: bool = Field(default=True, alias="emailVerified")
first_name: str = Field(default="E2E", alias="firstName")
last_name: str = Field(default="Tester", alias="lastName")
enabled: bool = True
groups: tuple[str, ...]
credentials: tuple[PasswordCredential, ...]
required_actions: tuple[str, ...] = Field(default=(), alias="requiredActions")
def created_id(write: ExternalWrite, context: str) -> str:
"""The new resource's id, which Keycloak returns only as the last segment of
the Location header on a 201."""
if write.status_code != 201:
pytest.fail(f"Keycloak refused to create {context}: HTTP {write.status_code} {write.body[:300]}")
if not write.location or write.location.endswith("/"):
pytest.fail(f"Keycloak created {context} without a resource id in its Location header")
return write.location.rsplit("/", 1)[-1]
@dataclass(frozen=True, slots=True)
class Identity:
"""One provisioned IdP user: the `sub` the proxy will see, the credential the
test signs in with, and the group whose name the litellm team carries."""
user_id: str
username: str
password: str = field(repr=False)
groups: tuple[str, ...]
group_ids: tuple[str, ...]
@property
def group(self) -> str:
if len(self.groups) != 1:
raise ValueError("A single-group identity is required")
return self.groups[0]
@property
def group_id(self) -> str:
if len(self.group_ids) != 1:
raise ValueError("A single-group identity is required")
return self.group_ids[0]
@dataclass(frozen=True, slots=True)
class Keycloak:
base_url: str
realm: str
admin_username: str
admin_password: str = field(repr=False)
strict_cleanup: bool = False
def with_strict_cleanup(self) -> Keycloak:
return replace(self, strict_cleanup=True)
@property
def issuer(self) -> str:
return f"{self.base_url}/realms/{self.realm}"
@property
def jwks_url(self) -> str:
return f"{self.issuer}/protocol/openid-connect/certs"
def token_url(self, realm: str) -> str:
return f"{self.base_url}/realms/{realm}/protocol/openid-connect/token"
def _admin_url(self, path: str) -> str:
return f"{self.base_url}/admin/realms/{self.realm}{path}"
def _admin_headers(self) -> AuthHeaders:
"""A fresh admin token per call: the master realm's tokens are short lived,
and a cached one would expire in the middle of a slow test."""
form: Final = TokenGrantForm(client_id="admin-cli", username=self.admin_username, password=self.admin_password)
result: Final = post_form_external(self.token_url("master"), form=form, response_type=TokenResponse)
return AuthHeaders(authorization=f"Bearer {self._token(result, 'the Keycloak admin credential')}")
def _token(self, result: Result[TokenResponse], context: str) -> str:
match result:
case Success(data=granted):
return granted.access_token
case NetworkError(message=message):
return pytest.fail(f"No live Keycloak at {self.base_url} for {context}: {message}. {_START_HINT}")
case _:
return pytest.fail(f"Keycloak refused {context}: {result}")
@step("create Keycloak group")
def create_group(self, name: str) -> str:
return created_id(
post_json_external(
self._admin_url("/groups"), headers=self._admin_headers(), json=GroupCreateBody(name=name)
),
f"group {name}",
)
@step("create Keycloak user")
def create_user(
self, *, username: str, email: str, password: str, group: str | None = None, groups: tuple[str, ...] = ()
) -> str:
return created_id(
post_json_external(
self._admin_url("/users"),
headers=self._admin_headers(),
json=UserCreateBody(
username=username,
email=email,
groups=(group,) if group is not None else groups,
credentials=(PasswordCredential(value=password),),
),
),
f"user {username}",
)
@step("delete Keycloak user")
def delete_user(self, user_id: str) -> None:
self._delete(f"/users/{user_id}")
@step("delete Keycloak group")
def delete_group(self, group_id: str) -> None:
self._delete(f"/groups/{group_id}")
@step("check the Keycloak resource is gone")
def assert_absent(self, kind: Literal["users", "groups", "clients"], resource_id: str) -> None:
result: Final = get_external(
self._admin_url(f"/{kind}/{resource_id}"),
headers=self._admin_headers(),
response_type=NoBody,
)
assert isinstance(result, UnknownApiError) and result.status_code == 404, (
f"Owned IdP {kind} still exists: {result}"
)
def _delete(self, path: str) -> None:
try:
headers: Final = self._admin_headers()
except pytest.fail.Exception as exc:
if self.strict_cleanup:
raise RuntimeError(f"Keycloak cleanup could not authenticate for {path}") from exc
warnings.warn(f"Keycloak cleanup could not authenticate for {path}: {exc}", RuntimeWarning, stacklevel=2)
return
result: Final = delete_external(self._admin_url(path), headers=headers)
if result.status_code not in (204, 404):
if self.strict_cleanup:
raise RuntimeError(f"Keycloak cleanup failed for {path}: HTTP {result.status_code}")
warnings.warn(
f"Keycloak cleanup failed for {path}: HTTP {result.status_code} {result.body[:300]}",
RuntimeWarning,
stacklevel=2,
)
@step("provision a Keycloak user in a new group")
def provision(self, *, marker: str, group: str, defer: Callable[[Callable[[], object]], None]) -> Identity:
"""Create `group` and a user in it, credentialed with a password generated
for this test alone, and hand back the identity a token can be minted for."""
return self.provision_groups(marker=marker, groups=(group,), defer=defer)
@step("provision a Keycloak user in new groups")
def provision_groups(
self, *, marker: str, groups: tuple[str, ...], defer: Callable[[Callable[[], object]], None]
) -> Identity:
def provision_group(name: str) -> str:
created: Final = self.create_group(name)
defer(lambda: self.delete_group(created))
return created
group_ids: Final = tuple(provision_group(group) for group in groups)
return self.provision_user(marker=marker, groups=groups, group_ids=group_ids, defer=defer)
@step("provision a Keycloak user in existing groups")
def provision_user(
self,
*,
marker: str,
groups: tuple[str, ...],
group_ids: tuple[str, ...],
defer: Callable[[Callable[[], object]], None],
) -> Identity:
username: Final = f"e2e-jwt-user-{marker}"
password: Final = secrets.token_urlsafe(24)
user_id: Final = self.create_user(
username=username, email=f"{username}@example.com", password=password, groups=groups
)
defer(lambda: self.delete_user(user_id))
return Identity(user_id=user_id, username=username, password=password, groups=groups, group_ids=group_ids)
@step("sign in to Keycloak for an access token")
def access_token(
self, identity: Identity, *, client_id: str = TESTS_CLIENT_ID, issuer_host: str | None = None
) -> str:
"""Sign `identity` in through the direct-access grant and hand back the
access token Keycloak signed, exactly as it came off the wire."""
result: Final = post_form_external(
self.token_url(self.realm),
form=TokenGrantForm(client_id=client_id, username=identity.username, password=identity.password),
response_type=TokenResponse,
headers=TokenRequestHeaders(host=issuer_host),
)
return self._token(result, f"a token for {identity.username}")
@step("read Keycloak's OIDC discovery document")
def discovery(self) -> Discovery:
return unwrap(get_external(f"{self.issuer}/.well-known/openid-configuration", response_type=Discovery))
@step("register a Keycloak browser client")
def browser_client(self, *, callback_url: str, defer: Callable[[Callable[[], object]], None]) -> BrowserClient:
client: Final = BrowserClient(
client_id=f"e2e-browser-{secrets.token_hex(8)}",
secret=secrets.token_urlsafe(32),
callback_url=callback_url,
)
resource_id: Final = created_id(
post_json_external(
self._admin_url("/clients"),
headers=self._admin_headers(),
json=BrowserClientBody(
clientId=client.client_id,
secret=client.secret,
redirectUris=(callback_url,),
),
),
"browser client",
)
defer(lambda: self._delete(f"/clients/{resource_id}"))
configured: Final = unwrap(
get_external(
self._admin_url(f"/clients/{resource_id}"),
headers=self._admin_headers(),
response_type=BrowserClientBody,
)
)
assert configured.redirect_uris == (callback_url,)
assert configured.standard_flow_enabled and not configured.public_client
assert configured.attributes.pkce == "S256"
return client
@step("sign in to Keycloak via the browser client")
def browser_token(self, identity: Identity, client: BrowserClient) -> str:
return self._token(
post_form_external(
self.token_url(self.realm),
form=TokenGrantForm(
client_id=client.client_id,
client_secret=client.secret,
username=identity.username,
password=identity.password,
scope="openid email",
),
response_type=TokenResponse,
),
"browser-profile identity mapping",
)
@step("read Keycloak userinfo for the token")
def userinfo(self, token: str) -> UserInfo:
return unwrap(
get_external(
f"{self.issuer}/protocol/openid-connect/userinfo",
headers=AuthHeaders(authorization=f"Bearer {token}"),
response_type=UserInfo,
)
)
def keycloak_from_env() -> Keycloak:
admin_username: Final = os.environ.get(KEYCLOAK_ADMIN_USER_ENV, "").strip()
admin_password: Final = os.environ.get(KEYCLOAK_ADMIN_PASSWORD_ENV, "").strip()
if not admin_username or not admin_password:
pytest.fail(
f"The JWT suite needs {KEYCLOAK_ADMIN_USER_ENV} and {KEYCLOAK_ADMIN_PASSWORD_ENV} to provision "
f"identities in its Keycloak realm, and neither may be empty. {_START_HINT}"
)
return Keycloak(
base_url=os.environ.get(KEYCLOAK_URL_ENV, DEFAULT_KEYCLOAK_URL).rstrip("/"),
realm=os.environ.get(KEYCLOAK_REALM_ENV, "").strip() or DEFAULT_REALM,
admin_username=admin_username,
admin_password=admin_password,
)
class TokenClaims(BaseModel):
sub: str
iss: str
aud: str | tuple[str, ...]
exp: int
scope: str = ""
groups: tuple[str, ...] = ()
class Discovery(BaseModel):
issuer: str
authorization_endpoint: str
token_endpoint: str
userinfo_endpoint: str
jwks_uri: str
class UserInfo(BaseModel):
sub: str
email: str
class BrowserAttributes(BaseModel):
pkce: str = Field(default="S256", alias="pkce.code.challenge.method")
class AudienceConfig(BaseModel):
audience: str = Field(default="litellm-e2e", alias="included.custom.audience")
access_token: str = Field(default="true", alias="access.token.claim")
id_token: str = Field(default="false", alias="id.token.claim")
class AudienceMapper(BaseModel):
name: str = "litellm-audience"
protocol: str = "openid-connect"
mapper: str = Field(default="oidc-audience-mapper", alias="protocolMapper")
config: AudienceConfig = Field(default_factory=AudienceConfig)
class BrowserClientBody(BaseModel):
client_id: str = Field(alias="clientId")
secret: str = Field(repr=False)
redirect_uris: tuple[str, ...] = Field(alias="redirectUris")
enabled: bool = True
public_client: bool = Field(default=False, alias="publicClient")
standard_flow_enabled: bool = Field(default=True, alias="standardFlowEnabled")
direct_access_grants_enabled: bool = Field(default=True, alias="directAccessGrantsEnabled")
default_client_scopes: tuple[str, ...] = Field(default=("email", "basic"), alias="defaultClientScopes")
attributes: BrowserAttributes = Field(default_factory=BrowserAttributes)
protocol_mappers: tuple[AudienceMapper, ...] = Field(default=(AudienceMapper(),), alias="protocolMappers")
@dataclass(frozen=True, slots=True)
class BrowserClient:
client_id: str
secret: str = field(repr=False)
callback_url: str
def environment(self, discovery: Discovery) -> dict[str, str]:
return {
"GENERIC_CLIENT_ID": self.client_id,
"GENERIC_CLIENT_SECRET": self.secret,
"GENERIC_USER_ID_ATTRIBUTE": "sub",
"GENERIC_AUTHORIZATION_ENDPOINT": discovery.authorization_endpoint,
"GENERIC_TOKEN_ENDPOINT": discovery.token_endpoint,
"GENERIC_USERINFO_ENDPOINT": discovery.userinfo_endpoint,
"GENERIC_CLIENT_USE_PKCE": "true",
"GENERIC_SCOPE": "openid email",
}
def token_claims(token: str) -> TokenClaims:
payload: Final = token.split(".")[1]
return TokenClaims.model_validate_json(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
def _signal_process_group(process_id: int, signum: int) -> bool:
try:
os.killpg(process_id, signum)
except ProcessLookupError:
return False
return True
@step("stop the child process group")
def stop_process_group(child: subprocess.Popen[bytes]) -> None:
_signal_process_group(child.pid, signal.SIGTERM)
deadline: Final = time.monotonic() + 5
while _process_group_exists(child.pid):
child.poll()
if time.monotonic() >= deadline:
_signal_process_group(child.pid, signal.SIGKILL)
break
time.sleep(0.05)
child.wait()
def _process_group_exists(process_id: int) -> bool:
try:
os.killpg(process_id, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
@step("run a command under the OIDC browser profile")
def run_oidc_profile(proxy_url: str, command: list[str]) -> int:
idp: Final = keycloak_from_env().with_strict_cleanup()
with ExitStack() as cleanup:
def terminate(signum: int, frame: FrameType | None) -> None:
raise SystemExit(128 + signum)
previous: Final = signal.signal(signal.SIGTERM, terminate)
cleanup.callback(signal.signal, signal.SIGTERM, previous)
def defer(callback: Callable[[], object]) -> None:
cleanup.callback(callback)
client: Final = idp.browser_client(callback_url=f"{proxy_url.rstrip('/')}/sso/callback", defer=defer)
environment: Final = {**os.environ, **client.environment(idp.discovery()), "PROXY_BASE_URL": proxy_url}
with subprocess.Popen(command, env=environment, start_new_session=True) as child:
try:
return child.wait()
finally:
stop_process_group(child)
if __name__ == "__main__":
if len(sys.argv) < 3:
raise SystemExit("Usage: idp.py PROXY_URL COMMAND [ARG ...]; requires a running test IdP")
raise SystemExit(run_oidc_profile(sys.argv[1], sys.argv[2:]))