From 507f6d58a53fb9e6a46f1c7910da8d0f3a5ffd38 Mon Sep 17 00:00:00 2001 From: Sergio Gimenez Date: Wed, 23 Sep 2026 07:59:35 +0200 Subject: [PATCH] feat(fm): answer GetFederationDetails as the partner Two FMs couldn't read each other's federation details: FM served only DELETE on /{federationContextId}/partner, so a partner's GET got a 405 back, which the OEG -> FM -> FM e2e run caught. FM now returns its network codes and platformCaps. Offered zones and the edge-discovery and LCM endpoints are left out rather than sent empty, because FM has no zone catalogue or endpoint config yet and OPG.04 marks none of them nullable. --- .../api/ewbi/v1/management.py | 19 ++++ .../application/federation.py | 20 +++- src/federation_manager/contracts/ewbi.py | 5 + tests/test_inbound_federation_details.py | 106 ++++++++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 tests/test_inbound_federation_details.py diff --git a/src/federation_manager/api/ewbi/v1/management.py b/src/federation_manager/api/ewbi/v1/management.py index 2dace4c..793aafe 100644 --- a/src/federation_manager/api/ewbi/v1/management.py +++ b/src/federation_manager/api/ewbi/v1/management.py @@ -7,6 +7,7 @@ from federation_manager.api.security import get_bearer_token from federation_manager.application.authentication import PartnerAuthenticator from federation_manager.application.federation import InboundFederationService from federation_manager.contracts.ewbi import ( + FederationDetails, FederationHealthInfo, FederationHealthResponse, FederationRequestData, @@ -74,6 +75,24 @@ async def create_federation( return accepted +@router.get( + "/{federationContextId}/partner", + operation_id="GetFederationDetails", + response_model=FederationDetails, + # every field is optional in OPG.04 and none is nullable: absent means left out + response_model_exclude_none=True, + responses=EWBI_ERROR_RESPONSES, +) +async def get_federation_details( + federationContextId: FederationContextIdPath, # noqa: N803 - GSMA path template name + auth: Annotated[PartnerAuthenticator, Depends(get_partner_authenticator)], + service: Annotated[InboundFederationService, Depends(get_inbound_federation_service)], + token: Annotated[str, Depends(get_bearer_token)], +) -> FederationDetails: + partner = await auth.authenticate(token) + return await service.details(partner, federationContextId) + + @router.delete( "/{federationContextId}/partner", operation_id="DeleteFederationDetails", diff --git a/src/federation_manager/application/federation.py b/src/federation_manager/application/federation.py index 9d22b74..45893a0 100644 --- a/src/federation_manager/application/federation.py +++ b/src/federation_manager/application/federation.py @@ -6,6 +6,7 @@ from uuid import UUID, uuid4 from pydantic import ValidationError from federation_manager.contracts.ewbi import ( + FederationDetails, FederationRequestData, FederationResponseData, MobileNetworkIds, @@ -216,8 +217,25 @@ class InboundFederationService: partner_op_federation_id=self._local.federation_id, ) + async def details(self, partner: PartnerOP, federation_context_id: str) -> FederationDetails: + await self._live_inbound(partner, federation_context_id) + # OPG.04 asks for offered zones and our edge-discovery/LCM endpoints too; FM has no + # zone catalogue or endpoint configuration yet, so those stay absent rather than empty. + return FederationDetails( + platform_caps=list(self._local.platform_caps), + allowed_mobile_network_ids=MobileNetworkIds( + mcc=self._local.mcc, mncs=list(self._local.mncs) + ), + ) + async def terminate(self, partner: PartnerOP, federation_context_id: str) -> None: + context = await self._live_inbound(partner, federation_context_id) + await self._contexts.set_status(context.id, TERMINATED) + + async def _live_inbound( + self, partner: PartnerOP, federation_context_id: str + ) -> FederationContext: context = await self._contexts.find_inbound(partner.id, federation_context_id) if context is None or context.is_terminated(): raise FederationContextUnknown(partner.id) - await self._contexts.set_status(context.id, TERMINATED) + return context diff --git a/src/federation_manager/contracts/ewbi.py b/src/federation_manager/contracts/ewbi.py index c37724f..f0c4ff1 100644 --- a/src/federation_manager/contracts/ewbi.py +++ b/src/federation_manager/contracts/ewbi.py @@ -182,6 +182,11 @@ class FederationDetails(BaseModel): platform_caps: list[str] | None = Field( default=None, validation_alias="platformCaps", serialization_alias="platformCaps" ) + allowed_mobile_network_ids: MobileNetworkIds | None = Field( + default=None, + validation_alias="allowedMobileNetworkIds", + serialization_alias="allowedMobileNetworkIds", + ) class ZoneRegistrationRequestData(BaseModel): diff --git a/tests/test_inbound_federation_details.py b/tests/test_inbound_federation_details.py new file mode 100644 index 0000000..e297776 --- /dev/null +++ b/tests/test_inbound_federation_details.py @@ -0,0 +1,106 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from uuid import uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from federation_manager.application.federation import LocalOperator +from federation_manager.dependencies import ( + get_federation_context_repo, + get_jwt_validator, + get_local_operator, + get_partner_repo, +) +from federation_manager.domain.models import FederationContext, PartnerOP, ValidatedClaims +from federation_manager.main import create_app +from tests.fakes import FakeJwtValidator, InMemoryFederationContextRepo, InMemoryPartnerRepo + +CLIENT_ID = "partner-a" +OTHER_CLIENT_ID = "partner-b" +TOKEN = "token-partner-a" +OTHER_TOKEN = "token-partner-b" +CONTEXT_ID = "fed-ctx-1" +URL = f"/operatorplatform/federation/v1/{CONTEXT_ID}/partner" +LOCAL = LocalOperator( + federation_id="oop-i2cat", + country_code="ES", + mcc="214", + mncs=("07", "01"), + partner_status_link="https://us.example/operatorplatform/federation/v1/partner-status", +) + + +@asynccontextmanager +async def _no_infra(app: FastAPI) -> AsyncIterator[None]: + yield + + +def _bearer(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _client(context_status: str = "available", direction: str = "inbound") -> TestClient: + partner = PartnerOP(id=uuid4(), mcc_mnc="208-01", oauth2_client_id=CLIENT_ID, status="active") + other = PartnerOP( + id=uuid4(), mcc_mnc="234-15", oauth2_client_id=OTHER_CLIENT_ID, status="active" + ) + context = FederationContext( + id=uuid4(), + partner_op_id=partner.id, + direction=direction, + federation_context_id=CONTEXT_ID, + status=context_status, + created_at=datetime(2026, 3, 1, tzinfo=timezone.utc), + ) + validator = FakeJwtValidator( + { + TOKEN: ValidatedClaims(client_id=CLIENT_ID, scopes={"fed-mgmt"}), + OTHER_TOKEN: ValidatedClaims(client_id=OTHER_CLIENT_ID, scopes={"fed-mgmt"}), + } + ) + app = create_app(lifespan=_no_infra) + app.dependency_overrides[get_partner_repo] = lambda: InMemoryPartnerRepo([partner, other]) + app.dependency_overrides[get_jwt_validator] = lambda: validator + app.dependency_overrides[get_local_operator] = lambda: LOCAL + app.dependency_overrides[get_federation_context_repo] = lambda: InMemoryFederationContextRepo( + [context] + ) + return TestClient(app) + + +def test_details_describe_this_operator_to_the_partner() -> None: + r = _client().get(URL, headers=_bearer(TOKEN)) + + assert r.status_code == 200 + # no zone catalogue or endpoint config yet, so those keys are absent, never null + assert r.json() == { + "platformCaps": ["serviceAPIs"], + "allowedMobileNetworkIds": {"mcc": "214", "mncs": ["07", "01"]}, + } + + +def test_terminated_context_is_404() -> None: + r = _client(context_status="terminated").get(URL, headers=_bearer(TOKEN)) + + assert r.status_code == 404 + assert r.json()["type"] == "urn:oop:ewbi:error:federation-context-unknown" + + +def test_our_outbound_context_is_not_readable_by_the_partner() -> None: + # the same id on our outbound side is our view of *their* federation, not one they created + r = _client(direction="outbound").get(URL, headers=_bearer(TOKEN)) + + assert r.status_code == 404 + + +def test_another_partners_context_is_not_visible() -> None: + r = _client().get(URL, headers=_bearer(OTHER_TOKEN)) + + assert r.status_code == 404 + assert "platformCaps" not in r.text + + +def test_missing_token_is_401() -> None: + assert _client().get(URL).status_code == 401 -- GitLab