Loading src/open_exposure_gateway/api/camara/edge_application_management/v0_1_0_alpha_1/schemas.py +18 −1 Changes for src/open_exposure_gateway/api/camara/edge_application_management/v0_1_0_alpha_1/schemas.py: 18 added lines, 1 removed line. Original line number Diff line number Diff line import re from datetime import datetime, timezone from enum import StrEnum from typing import Annotated, Any, Literal, Optional from uuid import UUID Loading Loading @@ -291,11 +292,27 @@ SubscriptionEventType = Literal[ ] class AccessTokenCredential(BaseModel): model_config = ConfigDict(extra="forbid") credentialType: Literal["ACCESSTOKEN"] accessToken: str = Field(min_length=1, max_length=8192, repr=False) accessTokenType: Literal["bearer"] accessTokenExpiresUtc: AwareDatetime @field_validator("accessTokenExpiresUtc") @classmethod def _expires_in_future(cls, value: datetime) -> datetime: if value <= datetime.now(timezone.utc): raise ValueError("accessTokenExpiresUtc must be in the future") return value class SubscriptionRequest(BaseModel): model_config = ConfigDict(extra="forbid") sink: str = Field(max_length=2048, pattern=r"^https://.+$") sinkCredential: Optional[dict[str, Any]] = None sinkCredential: Optional[AccessTokenCredential] = None types: Optional[list[SubscriptionEventType]] = Field(default=None, min_length=1, max_length=1) config: Optional[SubscriptionConfig] = None Loading src/open_exposure_gateway/api/error_handlers.py +3 −1 Changes for src/open_exposure_gateway/api/error_handlers.py: 3 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -86,7 +86,9 @@ def register_exception_handlers(app: FastAPI) -> None: problems.invalid_params(exc.errors()), headers={"x-correlator": x_correlator_header(request)}, ) return _error_response(request, 400, ErrorCode.INVALID_ARGUMENT, str(exc.errors())[:512]) # `input` is dropped: it can carry client secrets (e.g. sinkCredential.accessToken). errors = [{k: v for k, v in error.items() if k != "input"} for error in exc.errors()] return _error_response(request, 400, ErrorCode.INVALID_ARGUMENT, str(errors)[:512]) @app.exception_handler(BadRequestException) async def handle_bad_request(request: Request, exc: BadRequestException) -> Response: Loading tests/unit/test_eam_flows.py +46 −0 Changes for tests/unit/test_eam_flows.py: 46 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -55,6 +55,13 @@ ZONE_ID = uuid4() OTHER_ZONE_ID = UUID("bbbbbbbb-3333-4000-8000-000000000003") VALID_SINK_CREDENTIAL: dict[str, Any] = { "credentialType": "ACCESSTOKEN", "accessToken": "client-issued-sink-token-0123456789", "accessTokenType": "bearer", "accessTokenExpiresUtc": "2099-01-01T00:00:00Z", } CREATE_INSTANCE_BODY: dict[str, Any] = { "name": "myvideoapp_inst", "appId": str(APP_ID), Loading Loading @@ -227,6 +234,45 @@ class TestCreateAppInstanceFlow: assert response.status_code == 400 assert response.json()["code"] == "INVALID_ARGUMENT" @pytest.mark.parametrize( "credential", [ pytest.param( {**VALID_SINK_CREDENTIAL, "accessTokenExpiresUtc": "2020-01-01T00:00:00Z"}, id="expired", ), pytest.param( {**VALID_SINK_CREDENTIAL, "accessTokenExpiresUtc": "2030-01-01T00:00:00"}, id="naive-expiry", ), pytest.param( {k: v for k, v in VALID_SINK_CREDENTIAL.items() if k != "accessTokenType"}, id="missing-token-type", ), pytest.param( {k: v for k, v in VALID_SINK_CREDENTIAL.items() if k != "accessTokenExpiresUtc"}, id="missing-expiry", ), pytest.param({**VALID_SINK_CREDENTIAL, "credentialType": "PLAIN"}, id="wrong-type"), pytest.param({**VALID_SINK_CREDENTIAL, "accessTokenType": "mac"}, id="not-bearer"), pytest.param({**VALID_SINK_CREDENTIAL, "extra": "x"}, id="unknown-field"), ], ) def test_invalid_sink_credential_is_rejected_without_echoing_token( self, api_client: TestClient, credential: dict[str, Any] ) -> None: body = { **CREATE_INSTANCE_BODY, "subscriptionRequest": { "sink": "https://client.example.com/callback", "sinkCredential": credential, }, } response = api_client.post(f"{EAM_BASE}/appinstances", json=body) assert response.status_code == 400 assert response.json()["code"] == "INVALID_ARGUMENT" assert VALID_SINK_CREDENTIAL["accessToken"] not in response.text def test_delivers_callback_after_srm_completes( self, api_client: TestClient, Loading tests/unit/test_eam_service.py +12 −6 Changes for tests/unit/test_eam_service.py: 12 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -7,6 +7,7 @@ import pytest from structlog.testing import capture_logs from open_exposure_gateway.api.camara.edge_application_management.v0_1_0_alpha_1.schemas import ( AccessTokenCredential, AppInstanceInfo, AppInstanceStatus, ApplicationResources, Loading Loading @@ -107,6 +108,15 @@ async def _seed_registration( ) def _sink_credential(token: str = "raw-token") -> AccessTokenCredential: return AccessTokenCredential( credentialType="ACCESSTOKEN", accessToken=token, accessTokenType="bearer", accessTokenExpiresUtc=datetime.now(timezone.utc) + timedelta(days=1), ) def _make_srm_catalog() -> SRMCatalogPayload: return SRMCatalogPayload( service_specification=SRMServiceSpecEntry( Loading Loading @@ -1002,9 +1012,7 @@ class TestCreateAppInstance: edgeCloudZoneId=ZONE_ID, subscriptionRequest=SubscriptionRequest( sink="https://client.example.com/callback", sinkCredential={"credentialType": "ACCESSTOKEN", "accessToken": "raw-token"} if with_credential else None, sinkCredential=_sink_credential() if with_credential else None, types=[ "org.camaraproject.edge-application-management.v0.app-instance-status-change" ], Loading Loading @@ -1468,9 +1476,7 @@ class TestCreateAppDeployment: edgeCloudZones=[self.ZONE_A, self.ZONE_B], subscriptionRequest=SubscriptionRequest( sink="https://client.example.com/callback", sinkCredential={"credentialType": "ACCESSTOKEN", "accessToken": "raw-token"} if with_credential else None, sinkCredential=_sink_credential() if with_credential else None, types=[ "org.camaraproject.edge-application-management.v0.app-deployment-status-change" ], Loading Loading
src/open_exposure_gateway/api/camara/edge_application_management/v0_1_0_alpha_1/schemas.py +18 −1 Changes for src/open_exposure_gateway/api/camara/edge_application_management/v0_1_0_alpha_1/schemas.py: 18 added lines, 1 removed line. Original line number Diff line number Diff line import re from datetime import datetime, timezone from enum import StrEnum from typing import Annotated, Any, Literal, Optional from uuid import UUID Loading Loading @@ -291,11 +292,27 @@ SubscriptionEventType = Literal[ ] class AccessTokenCredential(BaseModel): model_config = ConfigDict(extra="forbid") credentialType: Literal["ACCESSTOKEN"] accessToken: str = Field(min_length=1, max_length=8192, repr=False) accessTokenType: Literal["bearer"] accessTokenExpiresUtc: AwareDatetime @field_validator("accessTokenExpiresUtc") @classmethod def _expires_in_future(cls, value: datetime) -> datetime: if value <= datetime.now(timezone.utc): raise ValueError("accessTokenExpiresUtc must be in the future") return value class SubscriptionRequest(BaseModel): model_config = ConfigDict(extra="forbid") sink: str = Field(max_length=2048, pattern=r"^https://.+$") sinkCredential: Optional[dict[str, Any]] = None sinkCredential: Optional[AccessTokenCredential] = None types: Optional[list[SubscriptionEventType]] = Field(default=None, min_length=1, max_length=1) config: Optional[SubscriptionConfig] = None Loading
src/open_exposure_gateway/api/error_handlers.py +3 −1 Changes for src/open_exposure_gateway/api/error_handlers.py: 3 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -86,7 +86,9 @@ def register_exception_handlers(app: FastAPI) -> None: problems.invalid_params(exc.errors()), headers={"x-correlator": x_correlator_header(request)}, ) return _error_response(request, 400, ErrorCode.INVALID_ARGUMENT, str(exc.errors())[:512]) # `input` is dropped: it can carry client secrets (e.g. sinkCredential.accessToken). errors = [{k: v for k, v in error.items() if k != "input"} for error in exc.errors()] return _error_response(request, 400, ErrorCode.INVALID_ARGUMENT, str(errors)[:512]) @app.exception_handler(BadRequestException) async def handle_bad_request(request: Request, exc: BadRequestException) -> Response: Loading
tests/unit/test_eam_flows.py +46 −0 Changes for tests/unit/test_eam_flows.py: 46 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -55,6 +55,13 @@ ZONE_ID = uuid4() OTHER_ZONE_ID = UUID("bbbbbbbb-3333-4000-8000-000000000003") VALID_SINK_CREDENTIAL: dict[str, Any] = { "credentialType": "ACCESSTOKEN", "accessToken": "client-issued-sink-token-0123456789", "accessTokenType": "bearer", "accessTokenExpiresUtc": "2099-01-01T00:00:00Z", } CREATE_INSTANCE_BODY: dict[str, Any] = { "name": "myvideoapp_inst", "appId": str(APP_ID), Loading Loading @@ -227,6 +234,45 @@ class TestCreateAppInstanceFlow: assert response.status_code == 400 assert response.json()["code"] == "INVALID_ARGUMENT" @pytest.mark.parametrize( "credential", [ pytest.param( {**VALID_SINK_CREDENTIAL, "accessTokenExpiresUtc": "2020-01-01T00:00:00Z"}, id="expired", ), pytest.param( {**VALID_SINK_CREDENTIAL, "accessTokenExpiresUtc": "2030-01-01T00:00:00"}, id="naive-expiry", ), pytest.param( {k: v for k, v in VALID_SINK_CREDENTIAL.items() if k != "accessTokenType"}, id="missing-token-type", ), pytest.param( {k: v for k, v in VALID_SINK_CREDENTIAL.items() if k != "accessTokenExpiresUtc"}, id="missing-expiry", ), pytest.param({**VALID_SINK_CREDENTIAL, "credentialType": "PLAIN"}, id="wrong-type"), pytest.param({**VALID_SINK_CREDENTIAL, "accessTokenType": "mac"}, id="not-bearer"), pytest.param({**VALID_SINK_CREDENTIAL, "extra": "x"}, id="unknown-field"), ], ) def test_invalid_sink_credential_is_rejected_without_echoing_token( self, api_client: TestClient, credential: dict[str, Any] ) -> None: body = { **CREATE_INSTANCE_BODY, "subscriptionRequest": { "sink": "https://client.example.com/callback", "sinkCredential": credential, }, } response = api_client.post(f"{EAM_BASE}/appinstances", json=body) assert response.status_code == 400 assert response.json()["code"] == "INVALID_ARGUMENT" assert VALID_SINK_CREDENTIAL["accessToken"] not in response.text def test_delivers_callback_after_srm_completes( self, api_client: TestClient, Loading
tests/unit/test_eam_service.py +12 −6 Changes for tests/unit/test_eam_service.py: 12 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -7,6 +7,7 @@ import pytest from structlog.testing import capture_logs from open_exposure_gateway.api.camara.edge_application_management.v0_1_0_alpha_1.schemas import ( AccessTokenCredential, AppInstanceInfo, AppInstanceStatus, ApplicationResources, Loading Loading @@ -107,6 +108,15 @@ async def _seed_registration( ) def _sink_credential(token: str = "raw-token") -> AccessTokenCredential: return AccessTokenCredential( credentialType="ACCESSTOKEN", accessToken=token, accessTokenType="bearer", accessTokenExpiresUtc=datetime.now(timezone.utc) + timedelta(days=1), ) def _make_srm_catalog() -> SRMCatalogPayload: return SRMCatalogPayload( service_specification=SRMServiceSpecEntry( Loading Loading @@ -1002,9 +1012,7 @@ class TestCreateAppInstance: edgeCloudZoneId=ZONE_ID, subscriptionRequest=SubscriptionRequest( sink="https://client.example.com/callback", sinkCredential={"credentialType": "ACCESSTOKEN", "accessToken": "raw-token"} if with_credential else None, sinkCredential=_sink_credential() if with_credential else None, types=[ "org.camaraproject.edge-application-management.v0.app-instance-status-change" ], Loading Loading @@ -1468,9 +1476,7 @@ class TestCreateAppDeployment: edgeCloudZones=[self.ZONE_A, self.ZONE_B], subscriptionRequest=SubscriptionRequest( sink="https://client.example.com/callback", sinkCredential={"credentialType": "ACCESSTOKEN", "accessToken": "raw-token"} if with_credential else None, sinkCredential=_sink_credential() if with_credential else None, types=[ "org.camaraproject.edge-application-management.v0.app-deployment-status-change" ], Loading