Commit f2b35e49 authored by George Papathanail's avatar George Papathanail
Browse files

feat(eam): type sinkCredential as AccessTokenCredential

parent c4256247
Loading
Loading
Loading
Loading
Loading
+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
@@ -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

+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
@@ -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:
+46 −0
Changes for tests/unit/test_eam_flows.py: 46 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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),
@@ -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,
+12 −6
Changes for tests/unit/test_eam_service.py: 12 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -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,
@@ -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(
@@ -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"
                ],
@@ -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"
                ],