Loading Makefile +5 −1 Changes for Makefile: 5 added lines, 1 removed line. Original line number Diff line number Diff line .PHONY: install clean lint format type precommit architecture test run .PHONY: install clean lint format type precommit architecture test integration run PIP := .venv/bin/pip DEV ?= false Loading Loading @@ -49,6 +49,10 @@ test: make install DEV=true .venv/bin/pytest integration: make install DEV=true .venv/bin/pytest -m integration run: make install .venv/bin/uvicorn "portal_service.main:create_app" --factory --reload --port 8000 pyproject.toml +7 −0 Changes for pyproject.toml: 7 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -64,3 +64,10 @@ forbidden_modules = ["portal_service.api", "portal_service.adapters"] [tool.pytest.ini_options] asyncio_mode = "auto" cache_dir = ".cache/pytest" # Integration tests need a running Keycloak, so a plain `pytest` leaves them out. # `pytest -m integration` (or `make integration`) runs them: the -m on the command # line replaces the one here. addopts = "-m 'not integration'" markers = [ "integration: needs a live Keycloak reachable at KEYCLOAK_ISSUER", ] tests/integration/test_keycloak_contract.py 0 → 100644 +91 −0 Changes for tests/integration/test_keycloak_contract.py: 91 added lines, 0 removed lines. Original line number Diff line number Diff line """ Contract test: the assumptions our Keycloak client makes, checked against a real realm. Unit tests fake the network, so they cannot notice a Keycloak upgrade that moves an endpoint, drops an algorithm or changes how clients authenticate. This reads the realm's own discovery document and compares it with what the client would do. Needs a running Keycloak: make integration Override the realm with: KEYCLOAK_ISSUER=http://host:port/realms/<realm> """ import os import httpx import pytest from portal_service.config import OIDCSettings pytestmark = pytest.mark.integration DEFAULT_ISSUER = "http://localhost:30081/realms/oop" @pytest.fixture(scope="module") def issuer() -> str: return os.environ.get("KEYCLOAK_ISSUER", DEFAULT_ISSUER) @pytest.fixture(scope="module") def discovery(issuer: str) -> dict[str, object]: url = f"{issuer}/.well-known/openid-configuration" try: response = httpx.get(url, timeout=10.0) except httpx.HTTPError as exc: # pragma: no cover - environment problem, not a bug pytest.fail(f"Keycloak not reachable at {url}: {exc}") response.raise_for_status() body: dict[str, object] = response.json() return body @pytest.fixture(scope="module") def settings(issuer: str) -> OIDCSettings: """The settings the portal would run with, endpoints derived from the issuer.""" return OIDCSettings( issuer=issuer, client_id="oop-portal", client_secret="unused-by-this-test", redirect_uri="http://localhost:8000/api/auth/callback", ) def test_issuer_is_what_we_were_given(discovery: dict[str, object], issuer: str) -> None: # Tokens carry this value as `iss`, and verification compares against it. assert discovery["issuer"] == issuer @pytest.mark.parametrize( "setting_name", ["authorization_endpoint", "token_endpoint", "end_session_endpoint", "jwks_uri"], ) def test_derived_endpoint_matches_the_realm( discovery: dict[str, object], settings: OIDCSettings, setting_name: str ) -> None: # config.py derives these from the issuer instead of reading discovery at start-up. assert getattr(settings, setting_name) == discovery[setting_name] def test_realm_still_supports_pkce_s256(discovery: dict[str, object]) -> None: assert "S256" in discovery["code_challenge_methods_supported"] # type: ignore[operator] def test_realm_still_supports_the_code_flow(discovery: dict[str, object]) -> None: assert "code" in discovery["response_types_supported"] # type: ignore[operator] grants = discovery["grant_types_supported"] assert "authorization_code" in grants # type: ignore[operator] assert "refresh_token" in grants # type: ignore[operator] def test_client_may_authenticate_with_secret_in_the_form(discovery: dict[str, object]) -> None: # KeycloakOIDCClient sends client_id/client_secret as form fields. assert "client_secret_post" in discovery["token_endpoint_auth_methods_supported"] # type: ignore[operator] def test_id_tokens_are_signed_with_rs256(discovery: dict[str, object]) -> None: # verification.py decodes with algorithms=["RS256"] only. assert "RS256" in discovery["id_token_signing_alg_values_supported"] # type: ignore[operator] def test_signing_keys_are_published(discovery: dict[str, object], settings: OIDCSettings) -> None: keys = httpx.get(settings.jwks_uri, timeout=10.0).json()["keys"] assert any(key.get("alg") == "RS256" or key.get("kty") == "RSA" for key in keys) Loading
Makefile +5 −1 Changes for Makefile: 5 added lines, 1 removed line. Original line number Diff line number Diff line .PHONY: install clean lint format type precommit architecture test run .PHONY: install clean lint format type precommit architecture test integration run PIP := .venv/bin/pip DEV ?= false Loading Loading @@ -49,6 +49,10 @@ test: make install DEV=true .venv/bin/pytest integration: make install DEV=true .venv/bin/pytest -m integration run: make install .venv/bin/uvicorn "portal_service.main:create_app" --factory --reload --port 8000
pyproject.toml +7 −0 Changes for pyproject.toml: 7 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -64,3 +64,10 @@ forbidden_modules = ["portal_service.api", "portal_service.adapters"] [tool.pytest.ini_options] asyncio_mode = "auto" cache_dir = ".cache/pytest" # Integration tests need a running Keycloak, so a plain `pytest` leaves them out. # `pytest -m integration` (or `make integration`) runs them: the -m on the command # line replaces the one here. addopts = "-m 'not integration'" markers = [ "integration: needs a live Keycloak reachable at KEYCLOAK_ISSUER", ]
tests/integration/test_keycloak_contract.py 0 → 100644 +91 −0 Changes for tests/integration/test_keycloak_contract.py: 91 added lines, 0 removed lines. Original line number Diff line number Diff line """ Contract test: the assumptions our Keycloak client makes, checked against a real realm. Unit tests fake the network, so they cannot notice a Keycloak upgrade that moves an endpoint, drops an algorithm or changes how clients authenticate. This reads the realm's own discovery document and compares it with what the client would do. Needs a running Keycloak: make integration Override the realm with: KEYCLOAK_ISSUER=http://host:port/realms/<realm> """ import os import httpx import pytest from portal_service.config import OIDCSettings pytestmark = pytest.mark.integration DEFAULT_ISSUER = "http://localhost:30081/realms/oop" @pytest.fixture(scope="module") def issuer() -> str: return os.environ.get("KEYCLOAK_ISSUER", DEFAULT_ISSUER) @pytest.fixture(scope="module") def discovery(issuer: str) -> dict[str, object]: url = f"{issuer}/.well-known/openid-configuration" try: response = httpx.get(url, timeout=10.0) except httpx.HTTPError as exc: # pragma: no cover - environment problem, not a bug pytest.fail(f"Keycloak not reachable at {url}: {exc}") response.raise_for_status() body: dict[str, object] = response.json() return body @pytest.fixture(scope="module") def settings(issuer: str) -> OIDCSettings: """The settings the portal would run with, endpoints derived from the issuer.""" return OIDCSettings( issuer=issuer, client_id="oop-portal", client_secret="unused-by-this-test", redirect_uri="http://localhost:8000/api/auth/callback", ) def test_issuer_is_what_we_were_given(discovery: dict[str, object], issuer: str) -> None: # Tokens carry this value as `iss`, and verification compares against it. assert discovery["issuer"] == issuer @pytest.mark.parametrize( "setting_name", ["authorization_endpoint", "token_endpoint", "end_session_endpoint", "jwks_uri"], ) def test_derived_endpoint_matches_the_realm( discovery: dict[str, object], settings: OIDCSettings, setting_name: str ) -> None: # config.py derives these from the issuer instead of reading discovery at start-up. assert getattr(settings, setting_name) == discovery[setting_name] def test_realm_still_supports_pkce_s256(discovery: dict[str, object]) -> None: assert "S256" in discovery["code_challenge_methods_supported"] # type: ignore[operator] def test_realm_still_supports_the_code_flow(discovery: dict[str, object]) -> None: assert "code" in discovery["response_types_supported"] # type: ignore[operator] grants = discovery["grant_types_supported"] assert "authorization_code" in grants # type: ignore[operator] assert "refresh_token" in grants # type: ignore[operator] def test_client_may_authenticate_with_secret_in_the_form(discovery: dict[str, object]) -> None: # KeycloakOIDCClient sends client_id/client_secret as form fields. assert "client_secret_post" in discovery["token_endpoint_auth_methods_supported"] # type: ignore[operator] def test_id_tokens_are_signed_with_rs256(discovery: dict[str, object]) -> None: # verification.py decodes with algorithms=["RS256"] only. assert "RS256" in discovery["id_token_signing_alg_values_supported"] # type: ignore[operator] def test_signing_keys_are_published(discovery: dict[str, object], settings: OIDCSettings) -> None: keys = httpx.get(settings.jwks_uri, timeout=10.0).json()["keys"] assert any(key.get("alg") == "RS256" or key.get("kty") == "RSA" for key in keys)