diff --git a/src/federation_manager/adapters/http/srm_client.py b/src/federation_manager/adapters/http/srm_client.py new file mode 100644 index 0000000000000000000000000000000000000000..8d054637d81618e8b0e84f4dab1310ca54069d24 --- /dev/null +++ b/src/federation_manager/adapters/http/srm_client.py @@ -0,0 +1,25 @@ +import httpx + +from federation_manager.contracts.srm import LocationQueryRequestV1, LocationQueryResponseV1 +from federation_manager.domain.errors import NetworkQueryNotApplicable, SrmQueryFailed + +LOCATION_PATH = "/internal/network-queries/location" + + +class HttpxSrmClient: + def __init__(self, client: httpx.AsyncClient, base_url: str) -> None: + self._client = client + self._base_url = base_url.rstrip("/") + + async def query_location(self, request: LocationQueryRequestV1) -> LocationQueryResponseV1: + try: + response = await self._client.post( + f"{self._base_url}{LOCATION_PATH}", + json=request.model_dump(mode="json", exclude_none=True), + ) + if response.status_code in (404, 422): + raise NetworkQueryNotApplicable + response.raise_for_status() + return LocationQueryResponseV1.model_validate(response.json()) + except (httpx.HTTPError, ValueError) as error: + raise SrmQueryFailed(str(error)[:200]) from None diff --git a/src/federation_manager/api/errors.py b/src/federation_manager/api/errors.py index 677ad04bdf422f130c4585034ff03c1910171ed8..50fc55b4e61634bcb96f5d493ee13829910f328c 100644 --- a/src/federation_manager/api/errors.py +++ b/src/federation_manager/api/errors.py @@ -13,6 +13,7 @@ from federation_manager.domain.errors import ( FederationContextMissing, FederationContextUnknown, IdempotencyKeyReused, + NetworkQueryNotApplicable, NoRouteMatched, PartnerEndpointConfigurationError, PartnerNotActive, @@ -21,6 +22,8 @@ from federation_manager.domain.errors import ( PartnerTokenConfigurationError, PartnerTokenRequestFailed, PartnerUnknown, + SrmQueryFailed, + UnsupportedServiceApi, problem_type, ) @@ -192,6 +195,40 @@ def register_exception_handlers(app: FastAPI) -> None: request.url.path, ) + @app.exception_handler(NetworkQueryNotApplicable) + async def _query_not_applicable( + request: Request, exc: NetworkQueryNotApplicable + ) -> JSONResponse: + return problem( + 422, + "service-not-applicable", + "Service Not Applicable", + "No local capability can serve the requested target.", + request.url.path, + ) + + @app.exception_handler(SrmQueryFailed) + async def _srm_query_failed(request: Request, exc: SrmQueryFailed) -> JSONResponse: + return problem( + 500, + "internal-error", + "Internal Error", + "The requested network information could not be retrieved.", + request.url.path, + ) + + @app.exception_handler(UnsupportedServiceApi) + async def _unsupported_service_api( + request: Request, exc: UnsupportedServiceApi + ) -> JSONResponse: + return problem( + 404, + "service-api-unsupported", + "Service API Not Supported", + "This operator does not offer the requested Service API over EWBI.", + request.url.path, + ) + @app.exception_handler(FederationContextUnknown) async def _context_unknown(request: Request, exc: FederationContextUnknown) -> JSONResponse: return problem( diff --git a/src/federation_manager/api/ewbi/v1/service.py b/src/federation_manager/api/ewbi/v1/service.py new file mode 100644 index 0000000000000000000000000000000000000000..99a265de7b4dd3cb23f015b9c9940fda40871096 --- /dev/null +++ b/src/federation_manager/api/ewbi/v1/service.py @@ -0,0 +1,29 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Path + +from federation_manager.api.errors import EWBI_ERROR_RESPONSES +from federation_manager.api.security import get_bearer_token +from federation_manager.application.queries import InboundQueryService +from federation_manager.contracts.ewbi import ApiForwardingRequest, ServiceApiResponse +from federation_manager.dependencies import get_inbound_query_service +from federation_manager.domain.ewbi import EWBI_BASE_PATH + +router = APIRouter(prefix=EWBI_BASE_PATH, tags=["ServiceAPIManagement"]) + +FederationContextIdPath = Annotated[str, Path(pattern=r"^[A-Za-z0-9][A-Za-z0-9-]*$")] + + +@router.post( + "/{federationContextId}/apiservice/{serviceAPINameVal}", + operation_id="APIForwarding", + responses=EWBI_ERROR_RESPONSES, +) +async def api_forwarding( + federationContextId: FederationContextIdPath, # noqa: N803 - GSMA path template name + serviceAPINameVal: str, # noqa: N803 - GSMA path template name + body: ApiForwardingRequest, + service: Annotated[InboundQueryService, Depends(get_inbound_query_service)], + token: Annotated[str, Depends(get_bearer_token)], +) -> ServiceApiResponse: + return await service.forward(token, federationContextId, serviceAPINameVal, body) diff --git a/src/federation_manager/application/queries.py b/src/federation_manager/application/queries.py new file mode 100644 index 0000000000000000000000000000000000000000..c0fe0477874cd39fdb4c5f3390d047aea0aca2ac --- /dev/null +++ b/src/federation_manager/application/queries.py @@ -0,0 +1,152 @@ +from collections.abc import Callable +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +from federation_manager.application.authentication import PartnerAuthenticator +from federation_manager.application.authorization import AgreementChecker +from federation_manager.contracts.ewbi import ( + ApiForwardingRequest, + ApiResponseBody, + ServiceApiResponse, +) +from federation_manager.contracts.srm import ( + CapabilityParameters, + CapabilityTarget, + DeviceTarget, + LocationQueryRequestV1, + SourceSpecificationV1, +) +from federation_manager.domain.errors import ( + AgreementViolation, + FederationContextUnknown, + UnsupportedServiceApi, +) +from federation_manager.domain.models import FederationTransaction +from federation_manager.domain.ports import ( + FederationContextRepositoryPort, + SrmQueryPort, + TransactionRepositoryPort, +) + +INBOUND = "inbound" +DEVICE_LOCATION = "DeviceLocation" +API_TYPES = {DEVICE_LOCATION: "device-location"} +OPG_SOURCE = SourceSpecificationV1(family="gsma_opg", api="APIForwarding", version="1.4.0") + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class InboundQueryService: + def __init__( + self, + authenticator: PartnerAuthenticator, + contexts: FederationContextRepositoryPort, + agreements: AgreementChecker, + transactions: TransactionRepositoryPort, + srm: SrmQueryPort, + *, + clock: Callable[[], datetime] = _utcnow, + id_factory: Callable[[], UUID] = uuid4, + ) -> None: + self._authenticator = authenticator + self._contexts = contexts + self._agreements = agreements + self._transactions = transactions + self._srm = srm + self._clock = clock + self._new_id = id_factory + + async def forward( + self, + token: str, + federation_context_id: str, + service_api: str, + request: ApiForwardingRequest, + ) -> ServiceApiResponse: + api_type = API_TYPES.get(service_api) + if api_type is None: + raise UnsupportedServiceApi(service_api) + + partner = await self._authenticator.authenticate(token) + context = await self._contexts.find_inbound(partner.id, federation_context_id) + if context is None or context.is_terminated(): + raise FederationContextUnknown(partner.id) + agreement = await self._agreements.require(partner, api_type) + specification_id = agreement.resolve_api_spec(api_type) + if specification_id is None: + raise AgreementViolation + + correlation_id = self._new_id() + transaction = FederationTransaction( + id=self._new_id(), + partner_op_id=partner.id, + agreement_id=agreement.id, + federation_context_row_id=context.id, + direction=INBOUND, + correlation_id=correlation_id, + external_txn_id=request.txn_identifier, + api_type=api_type, + status="pending", + request_summary={"api_type": api_type, "service_api": service_api}, + started_at=self._clock(), + ) + await self._transactions.add(transaction) + + try: + result = await self._srm.query_location( + LocationQueryRequestV1( + correlation_id=str(correlation_id), + app_provider_id=str(partner.id), + service_specification_id=specification_id, + target=CapabilityTarget(device=_device(request)), + parameters=_parameters(request), + source_spec=OPG_SOURCE, + ) + ) + except Exception: + await self._transactions.record_outcome( + transaction.id, status="failed", completed_at=self._clock() + ) + raise + + await self._transactions.record_outcome( + transaction.id, status="completed", completed_at=self._clock() + ) + return ServiceApiResponse( + customer_id=request.customer_id, + txn_identifier=request.txn_identifier, + api_response=ApiResponseBody( + media_type="application/json", + response_content=result.model_dump(mode="json", exclude_none=True), + ), + ) + + +def _device(request: ApiForwardingRequest) -> DeviceTarget: + device = request.service_api_body.api_content.get("device", {}) + if not isinstance(device, dict): + raise AgreementViolation + target = DeviceTarget( + phone_number=_text(device.get("phoneNumber")), + ipv4=_text( + (device.get("ipv4Address") or {}).get("publicAddress") + if isinstance(device.get("ipv4Address"), dict) + else None + ), + ipv6=_text(device.get("ipv6Address")), + network_access_id=_text(device.get("networkAccessIdentifier")), + ) + if not any((target.phone_number, target.ipv4, target.ipv6, target.network_access_id)): + raise AgreementViolation + return target + + +def _parameters(request: ApiForwardingRequest) -> CapabilityParameters | None: + max_age = request.service_api_body.api_content.get("maxAge") + return CapabilityParameters(max_age_seconds=max_age) if isinstance(max_age, int) else None + + +def _text(value: object) -> str | None: + return value if isinstance(value, str) and value else None diff --git a/src/federation_manager/contracts/srm.py b/src/federation_manager/contracts/srm.py index c9e824af5ed2d0a0495d37f270b5375ab996bfb3..9dec13bdc7ef5274d2b1e5f64d573fe756255f9e 100644 --- a/src/federation_manager/contracts/srm.py +++ b/src/federation_manager/contracts/srm.py @@ -1,6 +1,6 @@ # Vendored from srm/interface-contract.md §B/§C — hand-synced; SRM's spec wins. from datetime import datetime -from typing import Literal +from typing import Any, Literal from uuid import UUID from pydantic import BaseModel, ConfigDict, Field @@ -88,3 +88,41 @@ class SrmOperationCompletedV1(BaseModel): error: dict[str, object] | None = None correlation_id: str completed_at: datetime + + +class DeviceTarget(BaseModel): + phone_number: str | None = None + ipv4: str | None = None + ipv6: str | None = None + network_access_id: str | None = None + + +class CapabilityTarget(BaseModel): + device: DeviceTarget | None = None + + +class CapabilityParameters(BaseModel): + max_age_seconds: int | None = None + + +class SourceSpecificationV1(BaseModel): + family: Literal["camara", "tmf", "gsma_opg", "internal"] + api: str + version: str + + +class LocationQueryRequestV1(BaseModel): + correlation_id: str + app_provider_id: str + service_specification_id: UUID + target: CapabilityTarget + parameters: CapabilityParameters | None = None + source_spec: SourceSpecificationV1 | None = None + + +class LocationQueryResponseV1(BaseModel): + model_config = ConfigDict(extra="ignore") + + last_location_time: datetime + area: dict[str, Any] + srm_resolved_identifier: str | None = None diff --git a/src/federation_manager/core/config.py b/src/federation_manager/core/config.py index 7f8e393a8e2b744d876655072decc041b191012f..80cd56fdb7b274a6f36da3f979d1be99a5a2d20c 100644 --- a/src/federation_manager/core/config.py +++ b/src/federation_manager/core/config.py @@ -9,6 +9,7 @@ class Settings(BaseSettings): postgres_url: str = "postgresql+asyncpg://fm:fm@localhost:5433/fm_db" postgres_echo: bool = False nats_url: str = "nats://localhost:4222" + srm_internal_url: str = "http://localhost:8081" event_consumer_durable: str = "fm-event-worker" keycloak_issuer: str = "http://localhost:8090/realms/federation" diff --git a/src/federation_manager/dependencies.py b/src/federation_manager/dependencies.py index 4400200584dc8755c43be9fbe7c5737574cd0210..b2a6dd8ceb30840b066fbb5a4fe849aba48aa425 100644 --- a/src/federation_manager/dependencies.py +++ b/src/federation_manager/dependencies.py @@ -16,6 +16,7 @@ from federation_manager.application.authorization import AgreementChecker from federation_manager.application.deployment import InboundDeploymentService from federation_manager.application.federation import InboundFederationService, LocalOperator from federation_manager.application.outbound import OutboundFederationService +from federation_manager.application.queries import InboundQueryService from federation_manager.core.config import get_settings from federation_manager.domain.ports import ( AgreementRepositoryPort, @@ -26,6 +27,7 @@ from federation_manager.domain.ports import ( PartnerRepositoryPort, PartnerTokenProviderPort, RoutingRuleRepositoryPort, + SrmQueryPort, TransactionRepositoryPort, ) from federation_manager.domain.routing import RoutingResolver @@ -142,3 +144,24 @@ def get_inbound_deployment_service( transaction_repo, publisher, ) + + +def get_srm_client(request: Request) -> SrmQueryPort: + client: SrmQueryPort = request.app.state.srm_client + return client + + +def get_inbound_query_service( + authenticator: Annotated[PartnerAuthenticator, Depends(get_partner_authenticator)], + context_repo: Annotated[FederationContextRepositoryPort, Depends(get_federation_context_repo)], + agreement_repo: Annotated[AgreementRepositoryPort, Depends(get_agreement_repo)], + transaction_repo: Annotated[TransactionRepositoryPort, Depends(get_transaction_repo)], + srm: Annotated[SrmQueryPort, Depends(get_srm_client)], +) -> InboundQueryService: + return InboundQueryService( + authenticator, + context_repo, + AgreementChecker(agreement_repo), + transaction_repo, + srm, + ) diff --git a/src/federation_manager/domain/errors.py b/src/federation_manager/domain/errors.py index eb1f72a36f6de4ac62eb556e4df5f7fc4afa0606..0c542a8f13ce30522551a59e99c11fcae926dbf5 100644 --- a/src/federation_manager/domain/errors.py +++ b/src/federation_manager/domain/errors.py @@ -97,6 +97,23 @@ class IdempotencyKeyReused(FederationError): self.idempotency_key = idempotency_key +class NetworkQueryNotApplicable(FederationError): + def __init__(self) -> None: + super().__init__("no local capability can serve this target") + + +class SrmQueryFailed(FederationError): + def __init__(self, detail: str) -> None: + super().__init__("the local network query could not be completed") + self.detail = detail + + +class UnsupportedServiceApi(FederationError): + def __init__(self, service_api: str) -> None: + super().__init__(f"service API {service_api!r} is not supported") + self.service_api = service_api + + class NoRouteMatched(FederationError): def __init__(self, identifier_type: str) -> None: super().__init__(f"no routing rule matches the {identifier_type} identifier") diff --git a/src/federation_manager/domain/ports.py b/src/federation_manager/domain/ports.py index 430f8c16fa43b46111b81d6d827950881fedc81a..2bf34a0a4b0bf0123bc6a23b0a4985382bf1b138 100644 --- a/src/federation_manager/domain/ports.py +++ b/src/federation_manager/domain/ports.py @@ -2,6 +2,7 @@ from datetime import datetime from typing import Protocol from uuid import UUID +from federation_manager.contracts.srm import LocationQueryRequestV1, LocationQueryResponseV1 from federation_manager.domain.models import ( Agreement, EwbiResponse, @@ -35,6 +36,10 @@ class EwbiClientPort(Protocol): ) -> EwbiResponse: ... +class SrmQueryPort(Protocol): + async def query_location(self, request: LocationQueryRequestV1) -> LocationQueryResponseV1: ... + + class CallbackClientPort(Protocol): async def deliver(self, partner: PartnerOP, url: str, payload: dict[str, object]) -> bool: ... diff --git a/src/federation_manager/main.py b/src/federation_manager/main.py index 9e99b2c0fc9634c1ff41c4ffd16aff043bf494c0..eb45dde223cadc66bbc0cbeebb8dbc0ab9c41ce4 100644 --- a/src/federation_manager/main.py +++ b/src/federation_manager/main.py @@ -23,6 +23,7 @@ from federation_manager.adapters.databus.nats_adapter import ( ) from federation_manager.adapters.http.callback_client import HttpxCallbackClient from federation_manager.adapters.http.ewbi_client import HttpxEwbiClient +from federation_manager.adapters.http.srm_client import HttpxSrmClient from federation_manager.adapters.security.client_secret_token_provider import ( FileClientSecretTokenProvider, ) @@ -30,6 +31,7 @@ from federation_manager.adapters.security.keycloak_validator import KeycloakJwtV from federation_manager.api.errors import register_exception_handlers, register_problem_schemas from federation_manager.api.ewbi.v1.lcm import router as ewbi_lcm_router from federation_manager.api.ewbi.v1.management import router as ewbi_management_router +from federation_manager.api.ewbi.v1.service import router as ewbi_service_router from federation_manager.api.internal.federation import router as internal_federation_router from federation_manager.api.platform.health import router as health_router from federation_manager.application.events import OperationCompletedConsumer @@ -65,6 +67,7 @@ async def default_lifespan(app: FastAPI) -> AsyncIterator[None]: http_client, token_provider, allow_insecure=settings.allow_insecure_partner_endpoints ) app.state.ewbi_client = ewbi_client + app.state.srm_client = HttpxSrmClient(http_client, settings.srm_internal_url) app.state.callback_client = HttpxCallbackClient( http_client, token_provider, allow_insecure=settings.allow_insecure_partner_endpoints ) @@ -133,6 +136,7 @@ def create_app(lifespan: Lifespan[FastAPI] | None = None) -> FastAPI: app.include_router(health_router) app.include_router(ewbi_management_router) app.include_router(ewbi_lcm_router) + app.include_router(ewbi_service_router) app.include_router(internal_federation_router) return app diff --git a/tests/fakes.py b/tests/fakes.py index 2fa8b8be7d6bca9cf195d8cf4b469e3c3485f5fd..7679cc878c5d7d217b76a20a06f4bfe97918272c 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -1,7 +1,8 @@ from dataclasses import replace -from datetime import datetime +from datetime import datetime, timezone from uuid import UUID +from federation_manager.contracts.srm import LocationQueryRequestV1, LocationQueryResponseV1 from federation_manager.domain.errors import AuthenticationFailed from federation_manager.domain.models import ( Agreement, @@ -161,6 +162,24 @@ class InMemoryTransactionRepo: return next(iter(self.transactions.values())) +class FakeSrmClient: + def __init__( + self, response: LocationQueryResponseV1 | None = None, error: Exception | None = None + ) -> None: + self._response = response + self._error = error + self.requests: list[LocationQueryRequestV1] = [] + + async def query_location(self, request: LocationQueryRequestV1) -> LocationQueryResponseV1: + self.requests.append(request) + if self._error is not None: + raise self._error + return self._response or LocationQueryResponseV1( + last_location_time=datetime(2026, 9, 8, 12, 0, tzinfo=timezone.utc), + area={"areaType": "CIRCLE", "center": {"latitude": 41.4, "longitude": 2.2}}, + ) + + class RecordingCallbackClient: def __init__(self, succeeds: bool = True) -> None: self.succeeds = succeeds diff --git a/tests/test_inbound_query.py b/tests/test_inbound_query.py new file mode 100644 index 0000000000000000000000000000000000000000..79f5c618abeb5e1e569433385cdbb995f0e110d3 --- /dev/null +++ b/tests/test_inbound_query.py @@ -0,0 +1,215 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from typing import Any +from uuid import uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from federation_manager.dependencies import ( + get_agreement_repo, + get_federation_context_repo, + get_jwt_validator, + get_partner_repo, + get_srm_client, + get_transaction_repo, +) +from federation_manager.domain.errors import NetworkQueryNotApplicable, SrmQueryFailed +from federation_manager.domain.models import ( + Agreement, + FederationContext, + PartnerOP, + ValidatedClaims, +) +from federation_manager.main import create_app +from tests.fakes import ( + FakeJwtValidator, + FakeSrmClient, + InMemoryAgreementRepo, + InMemoryFederationContextRepo, + InMemoryPartnerRepo, + InMemoryTransactionRepo, +) + +CLIENT_ID = "partner-a" +TOKEN = "token-partner-a" +CONTEXT_ID = "fed-ctx-1" +SPEC_ID = uuid4() +MSISDN = "+34612345678" +URL = f"/operatorplatform/federation/v1/{CONTEXT_ID}/apiservice/DeviceLocation" + + +@asynccontextmanager +async def _no_infra(app: FastAPI) -> AsyncIterator[None]: + yield + + +def _client( + api_families: dict[str, Any] | None = None, + srm: FakeSrmClient | None = None, +) -> tuple[TestClient, InMemoryTransactionRepo, FakeSrmClient, PartnerOP]: + partner = PartnerOP(id=uuid4(), mcc_mnc="214-07", oauth2_client_id=CLIENT_ID, status="active") + context = FederationContext( + id=uuid4(), + partner_op_id=partner.id, + direction="inbound", + federation_context_id=CONTEXT_ID, + status="available", + created_at=datetime(2026, 3, 1, tzinfo=timezone.utc), + ) + agreement = Agreement( + id=uuid4(), + partner_op_id=partner.id, + permitted_api_types={"device-location"}, + permitted_zone_ids=set(), + app_mappings=(), + api_family_mappings={"device-location": SPEC_ID} if api_families is None else api_families, + valid_from=datetime(2026, 1, 1, tzinfo=timezone.utc), + valid_until=None, + status="active", + ) + transactions = InMemoryTransactionRepo() + srm = srm or FakeSrmClient() + + app = create_app(lifespan=_no_infra) + app.dependency_overrides[get_partner_repo] = lambda: InMemoryPartnerRepo([partner]) + app.dependency_overrides[get_jwt_validator] = lambda: FakeJwtValidator( + {TOKEN: ValidatedClaims(client_id=CLIENT_ID, scopes={"fed-mgmt"})} + ) + app.dependency_overrides[get_federation_context_repo] = lambda: InMemoryFederationContextRepo( + [context] + ) + app.dependency_overrides[get_agreement_repo] = lambda: InMemoryAgreementRepo([agreement]) + app.dependency_overrides[get_transaction_repo] = lambda: transactions + app.dependency_overrides[get_srm_client] = lambda: srm + return TestClient(app), transactions, srm, partner + + +def _body(**over: Any) -> dict[str, Any]: + body: dict[str, Any] = { + "apiServiceId": "DeviceLocation", + "customerID": str(uuid4()), + "customerInfo": "ACME Corp", + "txnIdentifier": "partner-txn-1", + "ServiceAPIBody": { + "mediaType": "application/json", + "APIContent": {"device": {"phoneNumber": MSISDN}, "maxAge": 60}, + }, + } + body.update(over) + return body + + +def _headers() -> dict[str, str]: + return {"Authorization": f"Bearer {TOKEN}"} + + +def test_partner_query_is_answered_from_srm() -> None: + client, transactions, srm, partner = _client() + body = _body() + + response = client.post(URL, json=body, headers=_headers()) + + assert response.status_code == 200 + answered = response.json() + assert answered["customerID"] == body["customerID"] + assert answered["txnIdentifier"] == "partner-txn-1" + assert answered["apiResponse"]["mediaType"] == "application/json" + assert answered["apiResponse"]["responseContent"]["area"]["areaType"] == "CIRCLE" + + asked = srm.requests[0] + assert asked.app_provider_id == str(partner.id) + assert asked.service_specification_id == SPEC_ID + assert asked.target.device is not None + assert asked.target.device.phone_number == MSISDN + assert asked.parameters is not None + assert asked.parameters.max_age_seconds == 60 + assert asked.source_spec is not None + assert asked.source_spec.family == "gsma_opg" + + audit = transactions.single() + assert audit.direction == "inbound" + assert audit.api_type == "device-location" + assert audit.status == "completed" + assert audit.operation_id is None + assert audit.external_txn_id == "partner-txn-1" + assert MSISDN not in str(audit.request_summary) + + +def test_an_unmapped_api_family_is_403() -> None: + client, _, srm, _ = _client(api_families={}) + + response = client.post(URL, json=_body(), headers=_headers()) + + assert response.status_code == 403 + assert response.json()["type"] == "urn:oop:ewbi:error:agreement-violation" + assert srm.requests == [] + + +def test_an_unsupported_service_api_is_404() -> None: + client, _, srm, _ = _client() + + response = client.post( + f"/operatorplatform/federation/v1/{CONTEXT_ID}/apiservice/SimSwap", + json=_body(apiServiceId="SimSwap"), + headers=_headers(), + ) + + assert response.status_code == 404 + assert response.json()["type"] == "urn:oop:ewbi:error:service-api-unsupported" + assert srm.requests == [] + + +def test_an_unknown_federation_context_is_404() -> None: + client, _, srm, _ = _client() + + response = client.post( + "/operatorplatform/federation/v1/other-ctx/apiservice/DeviceLocation", + json=_body(), + headers=_headers(), + ) + + assert response.status_code == 404 + assert srm.requests == [] + + +def test_a_body_without_a_device_identifier_is_403() -> None: + client, _, srm, _ = _client() + body = _body() + body["ServiceAPIBody"]["APIContent"] = {"device": {}} + + response = client.post(URL, json=body, headers=_headers()) + + assert response.status_code == 403 + assert srm.requests == [] + + +def test_an_srm_failure_is_500_and_the_audit_row_records_it() -> None: + client, transactions, _, _ = _client(srm=FakeSrmClient(error=SrmQueryFailed("connect refused"))) + + response = client.post(URL, json=_body(), headers=_headers()) + + assert response.status_code == 500 + assert response.json()["type"] == "urn:oop:ewbi:error:internal-error" + assert "connect refused" not in response.text + assert transactions.single().status == "failed" + + +def test_a_malformed_wrapper_is_422() -> None: + client, _, srm, _ = _client() + body = _body() + del body["txnIdentifier"] + + assert client.post(URL, json=body, headers=_headers()).status_code == 422 + assert srm.requests == [] + + +def test_srm_saying_it_cannot_serve_the_target_is_422_not_500() -> None: + client, transactions, _, _ = _client(srm=FakeSrmClient(error=NetworkQueryNotApplicable())) + + response = client.post(URL, json=_body(), headers=_headers()) + + assert response.status_code == 422 + assert response.json()["type"] == "urn:oop:ewbi:error:service-not-applicable" + assert transactions.single().status == "failed"