Commit 42aa2049 authored by George Papathanail's avatar George Papathanail
Browse files

fix: enforce CAMARA uuid pattern on EAM id inputs

parent 8ac20992
Loading
Loading
Loading
Loading
+13 −12
Changes for src/open_exposure_gateway/api/camara/edge_application_management/v0_1_0_alpha_1/router.py: 13 added lines, 12 removed lines.
Original line number Diff line number Diff line
from typing import Annotated, Any, Optional
from uuid import UUID, uuid4
from uuid import uuid4

from fastapi import APIRouter, Depends, Query, Request, Response, status

@@ -10,6 +10,7 @@ from open_exposure_gateway.api.camara.edge_application_management.v0_1_0_alpha_1
    AppInstanceInfo,
    AppManifest,
    AppManifestEnvelope,
    CamaraUuid,
    CreateAppDeploymentRequest,
    CreateAppInstanceRequest,
    EdgeCloudZone,
@@ -130,7 +131,7 @@ async def get_apps(
    ),
)
async def get_app(
    appId: UUID,
    appId: CamaraUuid,
    service: EdgeAppService,
    caller: Caller,
) -> Any:
@@ -182,7 +183,7 @@ async def submit_app(
    ),
)
async def delete_app(
    appId: UUID,
    appId: CamaraUuid,
    service: EdgeAppService,
    caller: Caller,
) -> Response:
@@ -248,8 +249,8 @@ async def create_app_instance(
async def get_app_instances(
    service: EdgeAppService,
    caller: Caller,
    appId: Annotated[Optional[UUID], Query()] = None,
    appInstanceId: Annotated[Optional[UUID], Query()] = None,
    appId: Annotated[Optional[CamaraUuid], Query()] = None,
    appInstanceId: Annotated[Optional[CamaraUuid], Query()] = None,
    region: Annotated[Optional[str], Query(max_length=64)] = None,
) -> Any:
    return await service.get_app_instances(
@@ -274,7 +275,7 @@ async def get_app_instances(
    ),
)
async def delete_app_instance(
    appInstanceId: UUID,
    appInstanceId: CamaraUuid,
    service: EdgeAppService,
    caller: Caller,
) -> Response:
@@ -337,8 +338,8 @@ async def create_app_deployment(
async def get_app_deployments(
    service: EdgeAppService,
    caller: Caller,
    appId: Annotated[Optional[UUID], Query()] = None,
    appDeploymentId: Annotated[Optional[UUID], Query()] = None,
    appId: Annotated[Optional[CamaraUuid], Query()] = None,
    appDeploymentId: Annotated[Optional[CamaraUuid], Query()] = None,
) -> Any:
    return await service.get_app_deployments(
        app_id=appId,
@@ -361,7 +362,7 @@ async def get_app_deployments(
    ),
)
async def delete_app_deployment(
    appDeploymentId: UUID,
    appDeploymentId: CamaraUuid,
    service: EdgeAppService,
    caller: Caller,
) -> Response:
@@ -387,7 +388,7 @@ async def delete_app_deployment(
        DownstreamServiceException(),
    ),
)
async def update_app_deployment(appDeploymentId: UUID) -> Any:
async def update_app_deployment(appDeploymentId: CamaraUuid) -> Any:
    raise NotImplementedException(message="Deployment update is not implemented")


@@ -405,7 +406,7 @@ async def update_app_deployment(appDeploymentId: UUID) -> Any:
)
async def get_clusters(
    region: Annotated[Optional[str], Query(max_length=64)] = None,
    clusterRef: Annotated[Optional[UUID], Query()] = None,
    edgeCloudZoneId: Annotated[Optional[UUID], Query()] = None,
    clusterRef: Annotated[Optional[CamaraUuid], Query()] = None,
    edgeCloudZoneId: Annotated[Optional[CamaraUuid], Query()] = None,
) -> Any:
    raise NotImplementedException(message="Cluster catalog is not implemented")
+29 −9
Changes for src/open_exposure_gateway/api/camara/edge_application_management/v0_1_0_alpha_1/schemas.py: 29 added lines, 9 removed lines.
Original line number Diff line number Diff line
import re
from datetime import datetime
from enum import StrEnum
from typing import Any, Literal, Optional
from typing import Annotated, Any, Literal, Optional
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, field_validator

# Pattern shared by AppId, AppInstanceId, AppDeploymentId, EdgeCloudZoneId and
# KubernetesClusterRef in the spec: lowercase, hyphenated, version 1-5, RFC variant.
_CAMARA_UUID_PATTERN = re.compile(
    r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
)


def _check_camara_uuid(value: Any) -> Any:
    # pydantic's UUID alone also accepts uppercase, unhyphenated, urn:uuid:, braced,
    # nil and version-0 forms, all of which the spec pattern rejects.
    text = str(value) if isinstance(value, UUID) else value
    if not isinstance(text, str) or not _CAMARA_UUID_PATTERN.fullmatch(text):
        raise ValueError(f"must match pattern {_CAMARA_UUID_PATTERN.pattern}")
    return value


# Input-side UUID for EAM request bodies, path and query params. Still a UUID downstream.
CamaraUuid = Annotated[UUID, BeforeValidator(_check_camara_uuid)]


class AppInstanceStatus(StrEnum):
@@ -215,7 +235,7 @@ RequiredResources = VmResources | ContainerResources | DockerComposeResources |
class AppManifest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    appId: Optional[UUID] = None
    appId: Optional[CamaraUuid] = None
    name: str = Field(max_length=64, pattern=r"^[A-Za-z][A-Za-z0-9_]{1,63}$")
    appProvider: str = Field(max_length=64, pattern=r"^[A-Za-z][A-Za-z0-9_]{7,63}$")
    version: str = Field(max_length=64)
@@ -283,9 +303,9 @@ class CreateAppInstanceRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    name: str = Field(max_length=64, pattern=r"^[A-Za-z][A-Za-z0-9_]{1,63}$")
    appId: UUID
    edgeCloudZoneId: UUID
    kubernetesClusterRef: Optional[UUID] = None
    appId: CamaraUuid
    edgeCloudZoneId: CamaraUuid
    kubernetesClusterRef: Optional[CamaraUuid] = None
    subscriptionRequest: Optional[SubscriptionRequest] = None


@@ -293,9 +313,9 @@ class CreateAppDeploymentRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    appDeploymentName: str = Field(max_length=64, pattern=r"^[A-Za-z][A-Za-z0-9_]{1,63}$")
    appId: UUID
    edgeCloudZones: list[UUID] = Field(min_length=1, max_length=100)
    kubernetesClusterRefs: Optional[list[UUID]] = Field(default=None, max_length=100)
    appId: CamaraUuid
    edgeCloudZones: list[CamaraUuid] = Field(min_length=1, max_length=100)
    kubernetesClusterRefs: Optional[list[CamaraUuid]] = Field(default=None, max_length=100)
    subscriptionRequest: Optional[SubscriptionRequest] = None


+87 −1
Changes for tests/unit/test_eam_endpoints.py: 87 added lines, 1 removed line.
Original line number Diff line number Diff line
from collections.abc import Generator
from typing import Any
from unittest.mock import ANY, AsyncMock
from uuid import uuid4
from uuid import UUID, uuid4

import pytest
from fastapi.testclient import TestClient
@@ -301,3 +301,89 @@ class TestDeleteAppDeployment:
    def test_malformed_deployment_id_returns_400(self, client: TestClient) -> None:
        response = client.delete(f"{EAM_BASE}/deployments/not-a-uuid")
        assert response.status_code == 400


# The spec pattern for every EAM id: lowercase, hyphenated, version 1-5, RFC variant.
_SPEC_VALID_ID = "3fa85f64-5717-4562-b3fc-2c963f66afa6"
_NON_CONFORMING_IDS = [
    pytest.param(_SPEC_VALID_ID.upper(), id="uppercase"),
    pytest.param(_SPEC_VALID_ID.replace("-", ""), id="no-hyphens"),
    pytest.param(f"urn:uuid:{_SPEC_VALID_ID}", id="urn"),
    pytest.param(f"{{{_SPEC_VALID_ID}}}", id="braced"),
    pytest.param("00000000-0000-0000-0000-000000000000", id="nil"),
    pytest.param("3fa85f64-5717-0562-b3fc-2c963f66afa6", id="version-0"),
]


def _assert_invalid_argument(response: Any) -> None:
    assert response.status_code == 400
    body = response.json()
    assert body["status"] == 400
    assert body["code"] == "INVALID_ARGUMENT"


class TestCamaraUuidFormat:
    @pytest.mark.parametrize("bad_id", _NON_CONFORMING_IDS)
    def test_body_field_rejects_non_conforming_id(
        self, client: TestClient, mock_eam_service: AsyncMock, bad_id: str
    ) -> None:
        response = client.post(
            f"{EAM_BASE}/appinstances", json={**_VALID_CREATE_INSTANCE, "appId": bad_id}
        )
        _assert_invalid_argument(response)
        mock_eam_service.create_app_instance.assert_not_called()

    @pytest.mark.parametrize("bad_id", _NON_CONFORMING_IDS)
    def test_body_list_item_rejects_non_conforming_id(
        self, client: TestClient, mock_eam_service: AsyncMock, bad_id: str
    ) -> None:
        response = client.post(
            f"{EAM_BASE}/deployments",
            json={**_VALID_CREATE_DEPLOYMENT, "edgeCloudZones": [str(_ZONE_ID), bad_id]},
        )
        _assert_invalid_argument(response)
        mock_eam_service.create_app_deployment.assert_not_called()

    @pytest.mark.parametrize("bad_id", _NON_CONFORMING_IDS)
    def test_path_param_rejects_non_conforming_id(
        self, client: TestClient, mock_eam_service: AsyncMock, bad_id: str
    ) -> None:
        response = client.delete(f"{EAM_BASE}/deployments/{bad_id}")
        _assert_invalid_argument(response)
        mock_eam_service.delete_app_deployment.assert_not_called()

    @pytest.mark.parametrize("bad_id", _NON_CONFORMING_IDS)
    def test_query_param_rejects_non_conforming_id(
        self, client: TestClient, mock_eam_service: AsyncMock, bad_id: str
    ) -> None:
        response = client.get(f"{EAM_BASE}/deployments", params={"appId": bad_id})
        _assert_invalid_argument(response)
        mock_eam_service.get_app_deployments.assert_not_called()

    def test_body_accepts_lowercase_v4(
        self, client: TestClient, mock_eam_service: AsyncMock
    ) -> None:
        response = client.post(
            f"{EAM_BASE}/appinstances", json={**_VALID_CREATE_INSTANCE, "appId": _SPEC_VALID_ID}
        )
        assert response.status_code == 202
        request = mock_eam_service.create_app_instance.call_args.kwargs["request"]
        assert request.appId == UUID(_SPEC_VALID_ID)

    def test_path_param_accepts_lowercase_v4(
        self, client: TestClient, mock_eam_service: AsyncMock
    ) -> None:
        response = client.delete(f"{EAM_BASE}/deployments/{_SPEC_VALID_ID}")
        assert response.status_code == 202
        kwargs = mock_eam_service.delete_app_deployment.call_args.kwargs
        assert kwargs["app_deployment_id"] == UUID(_SPEC_VALID_ID)

    def test_query_param_accepts_lowercase_v4(
        self, client: TestClient, mock_eam_service: AsyncMock
    ) -> None:
        mock_eam_service.get_app_deployments.return_value = []
        response = client.get(f"{EAM_BASE}/deployments", params={"appId": _SPEC_VALID_ID})
        assert response.status_code == 200
        assert mock_eam_service.get_app_deployments.call_args.kwargs["app_id"] == UUID(
            _SPEC_VALID_ID
        )
+11 −5
Changes for tests/unit/test_eam_mapper.py: 11 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -60,8 +60,8 @@ from open_exposure_gateway.domain.models import (
    AppInstanceState,
)

APP_ID = UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
ZONE_ID = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
APP_ID = UUID("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
ZONE_ID = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
INSTANCE_ID = UUID("cccccccc-cccc-cccc-cccc-cccccccccccc")
OP_ID = UUID("dddddddd-dddd-dddd-dddd-dddddddddddd")
APP_REGISTRATION_ID = UUID("ffffffff-ffff-ffff-ffff-ffffffffffff")
@@ -1018,7 +1018,7 @@ class TestBuildDeployTargets:
            appDeploymentName="video_analytics_eu",
            appId=APP_ID,
            edgeCloudZones=[self.ZONE_A],
            kubernetesClusterRefs=[self.CLUSTER_A, UUID(int=0)],
            kubernetesClusterRefs=[self.CLUSTER_A, UUID("dddddddd-0000-4000-8000-00000000000b")],
        )
        with pytest.raises(ValueError, match="kubernetesClusterRefs"):
            build_deploy_targets(request, [self.INSTANCE_A])
@@ -1051,8 +1051,14 @@ class TestBuildMultiZoneDeployCommand:
        assert cmd.deploy.placement_constraints == {}

    def test_carries_n_targets_for_multi_zone(self) -> None:
        zone_a, zone_b = UUID(int=1), UUID(int=2)
        instance_a, instance_b = UUID(int=3), UUID(int=4)
        zone_a, zone_b = (
            UUID("aaaaaaaa-0000-4000-8000-000000000001"),
            UUID("aaaaaaaa-0000-4000-8000-000000000002"),
        )
        instance_a, instance_b = (
            UUID("cccccccc-0000-4000-8000-000000000003"),
            UUID("cccccccc-0000-4000-8000-000000000004"),
        )
        request = CreateAppDeploymentRequest(
            appDeploymentName="video_analytics_eu",
            appId=APP_ID,
+2 −2
Changes for tests/unit/test_eam_service.py: 2 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -75,8 +75,8 @@ from tests.unit.fakes import (
    FakeOperationRepository,
)

APP_ID = UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
ZONE_ID = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
APP_ID = UUID("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
ZONE_ID = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
INSTANCE_ID = UUID("cccccccc-cccc-cccc-cccc-cccccccccccc")
APP_REGISTRATION_ID = UUID("ffffffff-ffff-ffff-ffff-ffffffffffff")
DEPLOYMENT_ID = UUID("11111111-1111-1111-1111-111111111111")