Loading src/open_exposure_gateway/core/config.py +8 −2 Original line number Diff line number Diff line Loading @@ -50,10 +50,16 @@ class ObservabilitySettings(BaseModel): class KeycloakSettings(BaseModel): issuer: str = "http://localhost:8080/realms/oeg" issuer: str = "http://localhost:8080/realms/oop" audience: str = "oeg" jwks_url: HttpUrl = HttpUrl("http://localhost:8080/realms/oeg/protocol/openid-connect/certs") jwks_url: HttpUrl = HttpUrl("http://localhost:8080/realms/oop/protocol/openid-connect/certs") algorithms: list[str] = ["RS256"] # Realm-specific claim names -- kept as config, not literals in dependencies.py, # since the realm/protocol-mapper names are owned by a separate Helm MR and can # still change. Defaults match that MR's current `organization` user-attribute # mapper and the standard OIDC `azp` (authorized party / calling client id). tenant_id_claim: str = "organization" app_provider_id_claim: str = "azp" class CallbackSettings(BaseModel): Loading src/open_exposure_gateway/dependencies.py +29 −11 Original line number Diff line number Diff line Loading @@ -3,6 +3,7 @@ from dataclasses import dataclass from typing import Annotated, Optional, cast from fastapi import Depends, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession Loading Loading @@ -39,6 +40,7 @@ from open_exposure_gateway.application.services.quality_on_demand_service import QualityOnDemandService, ) from open_exposure_gateway.core.config import get_settings from open_exposure_gateway.core.exceptions import UnauthorizedException from open_exposure_gateway.core.state import AppState from open_exposure_gateway.ports.database.callbacks import ( CallbackDeliveryRepository, Loading @@ -62,17 +64,6 @@ class CallerContext: app_provider_id: str def get_caller_context( request: Request, x_correlator: XCorrelatorHeader = None, ) -> CallerContext: return CallerContext( x_correlator=x_correlator_header(request), tenant_id="placeholder", # TODO: extract from JWT app_provider_id="placeholder", # TODO: extract from JWT ) def get_app_state(request: Request) -> AppState: return cast(AppState, request.app.state) Loading @@ -89,6 +80,33 @@ def get_token_verifier(request: Request) -> TokenVerifierPort: return get_app_state(request=request).token_verifier _bearer_scheme = HTTPBearer(auto_error=False) async def get_caller_context( request: Request, x_correlator: XCorrelatorHeader = None, credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer_scheme), token_verifier: TokenVerifierPort = Depends(get_token_verifier), ) -> CallerContext: if credentials is None: raise UnauthorizedException(message="Missing bearer token") claims = await token_verifier.verify(credentials.credentials) keycloak_settings = get_settings().keycloak_settings tenant_id = claims.get(keycloak_settings.tenant_id_claim) app_provider_id = claims.get(keycloak_settings.app_provider_id_claim) if not tenant_id or not app_provider_id: raise UnauthorizedException(message="Access token missing required claims") return CallerContext( x_correlator=x_correlator_header(request), tenant_id=tenant_id, app_provider_id=app_provider_id, ) def get_db_engine(request: Request) -> AsyncEngine: return get_app_state(request=request).db_engine Loading tests/unit/conftest.py +15 −0 Original line number Diff line number Diff line Loading @@ -3,11 +3,13 @@ from collections.abc import Callable, Generator from typing import Any import pytest from fastapi import Request from fastapi.testclient import TestClient from open_exposure_gateway.api.camara.edge_application_management.v0_1_0_alpha_1.router import ( BASE_PATH as EAM_BASE, ) from open_exposure_gateway.api.error_handlers import x_correlator_header from open_exposure_gateway.application.services.edge_application_management_service import ( EdgeApplicationManagementService, ) Loading @@ -18,6 +20,8 @@ from open_exposure_gateway.application.services.quality_on_demand_service import QualityOnDemandService, ) from open_exposure_gateway.dependencies import ( CallerContext, get_caller_context, get_database_health, get_edge_app_service, get_location_retrieval_service, Loading @@ -43,6 +47,16 @@ from tests.unit.fakes import ( ) def fake_caller_context(request: Request) -> CallerContext: """Override for get_caller_context: skips real JWT verification but keeps the x-correlator resolution real, since several tests assert on its propagation.""" return CallerContext( x_correlator=x_correlator_header(request), tenant_id="test_tenant", app_provider_id="test_app_provider", ) class LoggingTestClient(TestClient): """TestClient that prints each request/response; visible with pytest -s.""" Loading Loading @@ -268,6 +282,7 @@ def api_client( app.dependency_overrides[get_location_retrieval_service] = lambda: location_retrieval_service app.dependency_overrides[get_publisher] = lambda: fake_bus app.dependency_overrides[get_database_health] = lambda: True app.dependency_overrides[get_caller_context] = fake_caller_context # raise_server_exceptions=False: unhandled errors surface as the 500 envelope # a real client would see, so flow tests assert status codes, not tracebacks. yield LoggingTestClient(app, raise_server_exceptions=False) Loading tests/unit/test_eam_endpoints.py +3 −1 Original line number Diff line number Diff line Loading @@ -22,8 +22,9 @@ from open_exposure_gateway.application.services.edge_application_management_serv EdgeApplicationManagementService, ) from open_exposure_gateway.core.exceptions import NotFoundException from open_exposure_gateway.dependencies import get_edge_app_service from open_exposure_gateway.dependencies import get_caller_context, get_edge_app_service from open_exposure_gateway.main import app from tests.unit.conftest import fake_caller_context _ZONE_ID = uuid4() _APP_ID = uuid4() Loading Loading @@ -92,6 +93,7 @@ def mock_eam_service() -> AsyncMock: @pytest.fixture() def client(mock_eam_service: AsyncMock) -> Generator[TestClient, None, None]: app.dependency_overrides[get_edge_app_service] = lambda: mock_eam_service app.dependency_overrides[get_caller_context] = fake_caller_context yield TestClient(app) app.dependency_overrides.clear() Loading tests/unit/test_location_retrieval_flows.py +1 −1 Original line number Diff line number Diff line Loading @@ -108,7 +108,7 @@ class TestQueryTranslation: query = located_device.location_queries[0] assert query.target.device.phone_number == PHONE assert query.app_provider_id == "placeholder" assert query.app_provider_id == "test_app_provider" assert query.service_specification_id def test_max_age_zero_is_forwarded_not_dropped( Loading Loading
src/open_exposure_gateway/core/config.py +8 −2 Original line number Diff line number Diff line Loading @@ -50,10 +50,16 @@ class ObservabilitySettings(BaseModel): class KeycloakSettings(BaseModel): issuer: str = "http://localhost:8080/realms/oeg" issuer: str = "http://localhost:8080/realms/oop" audience: str = "oeg" jwks_url: HttpUrl = HttpUrl("http://localhost:8080/realms/oeg/protocol/openid-connect/certs") jwks_url: HttpUrl = HttpUrl("http://localhost:8080/realms/oop/protocol/openid-connect/certs") algorithms: list[str] = ["RS256"] # Realm-specific claim names -- kept as config, not literals in dependencies.py, # since the realm/protocol-mapper names are owned by a separate Helm MR and can # still change. Defaults match that MR's current `organization` user-attribute # mapper and the standard OIDC `azp` (authorized party / calling client id). tenant_id_claim: str = "organization" app_provider_id_claim: str = "azp" class CallbackSettings(BaseModel): Loading
src/open_exposure_gateway/dependencies.py +29 −11 Original line number Diff line number Diff line Loading @@ -3,6 +3,7 @@ from dataclasses import dataclass from typing import Annotated, Optional, cast from fastapi import Depends, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession Loading Loading @@ -39,6 +40,7 @@ from open_exposure_gateway.application.services.quality_on_demand_service import QualityOnDemandService, ) from open_exposure_gateway.core.config import get_settings from open_exposure_gateway.core.exceptions import UnauthorizedException from open_exposure_gateway.core.state import AppState from open_exposure_gateway.ports.database.callbacks import ( CallbackDeliveryRepository, Loading @@ -62,17 +64,6 @@ class CallerContext: app_provider_id: str def get_caller_context( request: Request, x_correlator: XCorrelatorHeader = None, ) -> CallerContext: return CallerContext( x_correlator=x_correlator_header(request), tenant_id="placeholder", # TODO: extract from JWT app_provider_id="placeholder", # TODO: extract from JWT ) def get_app_state(request: Request) -> AppState: return cast(AppState, request.app.state) Loading @@ -89,6 +80,33 @@ def get_token_verifier(request: Request) -> TokenVerifierPort: return get_app_state(request=request).token_verifier _bearer_scheme = HTTPBearer(auto_error=False) async def get_caller_context( request: Request, x_correlator: XCorrelatorHeader = None, credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer_scheme), token_verifier: TokenVerifierPort = Depends(get_token_verifier), ) -> CallerContext: if credentials is None: raise UnauthorizedException(message="Missing bearer token") claims = await token_verifier.verify(credentials.credentials) keycloak_settings = get_settings().keycloak_settings tenant_id = claims.get(keycloak_settings.tenant_id_claim) app_provider_id = claims.get(keycloak_settings.app_provider_id_claim) if not tenant_id or not app_provider_id: raise UnauthorizedException(message="Access token missing required claims") return CallerContext( x_correlator=x_correlator_header(request), tenant_id=tenant_id, app_provider_id=app_provider_id, ) def get_db_engine(request: Request) -> AsyncEngine: return get_app_state(request=request).db_engine Loading
tests/unit/conftest.py +15 −0 Original line number Diff line number Diff line Loading @@ -3,11 +3,13 @@ from collections.abc import Callable, Generator from typing import Any import pytest from fastapi import Request from fastapi.testclient import TestClient from open_exposure_gateway.api.camara.edge_application_management.v0_1_0_alpha_1.router import ( BASE_PATH as EAM_BASE, ) from open_exposure_gateway.api.error_handlers import x_correlator_header from open_exposure_gateway.application.services.edge_application_management_service import ( EdgeApplicationManagementService, ) Loading @@ -18,6 +20,8 @@ from open_exposure_gateway.application.services.quality_on_demand_service import QualityOnDemandService, ) from open_exposure_gateway.dependencies import ( CallerContext, get_caller_context, get_database_health, get_edge_app_service, get_location_retrieval_service, Loading @@ -43,6 +47,16 @@ from tests.unit.fakes import ( ) def fake_caller_context(request: Request) -> CallerContext: """Override for get_caller_context: skips real JWT verification but keeps the x-correlator resolution real, since several tests assert on its propagation.""" return CallerContext( x_correlator=x_correlator_header(request), tenant_id="test_tenant", app_provider_id="test_app_provider", ) class LoggingTestClient(TestClient): """TestClient that prints each request/response; visible with pytest -s.""" Loading Loading @@ -268,6 +282,7 @@ def api_client( app.dependency_overrides[get_location_retrieval_service] = lambda: location_retrieval_service app.dependency_overrides[get_publisher] = lambda: fake_bus app.dependency_overrides[get_database_health] = lambda: True app.dependency_overrides[get_caller_context] = fake_caller_context # raise_server_exceptions=False: unhandled errors surface as the 500 envelope # a real client would see, so flow tests assert status codes, not tracebacks. yield LoggingTestClient(app, raise_server_exceptions=False) Loading
tests/unit/test_eam_endpoints.py +3 −1 Original line number Diff line number Diff line Loading @@ -22,8 +22,9 @@ from open_exposure_gateway.application.services.edge_application_management_serv EdgeApplicationManagementService, ) from open_exposure_gateway.core.exceptions import NotFoundException from open_exposure_gateway.dependencies import get_edge_app_service from open_exposure_gateway.dependencies import get_caller_context, get_edge_app_service from open_exposure_gateway.main import app from tests.unit.conftest import fake_caller_context _ZONE_ID = uuid4() _APP_ID = uuid4() Loading Loading @@ -92,6 +93,7 @@ def mock_eam_service() -> AsyncMock: @pytest.fixture() def client(mock_eam_service: AsyncMock) -> Generator[TestClient, None, None]: app.dependency_overrides[get_edge_app_service] = lambda: mock_eam_service app.dependency_overrides[get_caller_context] = fake_caller_context yield TestClient(app) app.dependency_overrides.clear() Loading
tests/unit/test_location_retrieval_flows.py +1 −1 Original line number Diff line number Diff line Loading @@ -108,7 +108,7 @@ class TestQueryTranslation: query = located_device.location_queries[0] assert query.target.device.phone_number == PHONE assert query.app_provider_id == "placeholder" assert query.app_provider_id == "test_app_provider" assert query.service_specification_id def test_max_age_zero_is_forwarded_not_dropped( Loading