fix(fm): answer the partner registry and identity routes with ProblemDetails

OEG now relays the partner registry and /internal/identity to the portal (open-exposure-gateway, feat/partner-registry-relay). OEG passes an FM error through only when it is ProblemDetails; anything else becomes OEG's 502. On these routes FM still answered a bad body with FastAPI's own plain-JSON 422, so a typo in the portal's partner form reached the operator as "bad gateway" instead of naming the field.

sequenceDiagram
    participant P as Portal
    participant OEG as OEG
    participant FM as FM
    P->>OEG: POST /platform/v1/federation/partners {"mcc_mnc": "bad", ...}
    OEG->>FM: POST /internal/partners
    Note over FM: before: 422 application/json {"detail": [...]}
    Note over FM: after: 400 application/problem+json, invalidParams: [{param: "mcc_mnc"}]
    FM-->>OEG: 400 ProblemDetails
    OEG-->>P: 400, relayed unchanged
  • /internal/partners, /internal/partners/{id} and /internal/identity now count as OEG-relayed routes in api/errors.py, like /internal/partners/{id}/federations/... already did. A request validation error becomes 400 invalid-request with invalidParams, and an unexpected error becomes a ProblemDetails 500.
  • Nothing else changes on these routes: 409 registration conflict, 422 incomplete outbound credentials and 404 unknown partner were already ProblemDetails.
  • Other /internal routes (zones, /internal/federation/outbound) are unchanged.
  • tests/test_partner_registry.py: bad bodies now expect 400 plus the problem shape; there's a new test for a malformed partner id.

Tested: the partner, identity and federation test modules pass (82), and ruff and mypy are clean. End to end on the docker harness through OEG-A to FM-A: a bad mcc_mnc comes back 400 with invalidParams naming it.

Merge request reports

Loading
+7 −5
Changes for src/federation_manager/api/errors.py: 7 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -111,11 +111,13 @@ def problem(
    )


# The operator-facing federation lifecycle OEG proxies onto /internal (§L.3b). OEG relays only
# the statuses OPG.04 v1.4.0 declares for those operations (_PROBLEM_STATUSES) and turns
# anything else into its own 502, so on these routes FM maps its EWBI-side 403/502 onto that
# set; `type` still says which failure it was.
_PROXIED_PATH = re.compile(r"^/internal/partners/[^/]+/federations(/|$)")
# The /internal routes OEG relays for the portal: partner registry, our identity and the
# federation lifecycle. OEG relays only the statuses each route declares, as ProblemDetails,
# and turns anything else into its own 502, so on these routes FM maps its EWBI-side 403/502
# onto the OPG.04 v1.4.0 set (_PROBLEM_STATUSES); `type` still says which failure it was.
_PROXIED_PATH = re.compile(
    r"^/internal/(identity|partners(/[^/]+)?|partners/[^/]+/federations(/.*)?)$"
)

# Partner rejections that describe the operator's own request and are relayed as they are.
# Anything else the partner answers (its 401/403 is about our token, its 5xx about itself)
+15 −2
Changes for tests/test_partner_registry.py: 15 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -106,7 +106,13 @@ def test_partner_without_outbound_credentials_is_valid(client: TestClient) -> No
    ],
)
def test_invalid_registration_rejected(client: TestClient, overrides: dict[str, Any]) -> None:
    assert client.post(PARTNERS, json=_registration(**overrides)).status_code == 422
    response = client.post(PARTNERS, json=_registration(**overrides))

    # OEG relays this route, so a bad body is ProblemDetails like the rest of what it relays
    assert response.status_code == 400
    assert response.headers["content-type"] == "application/problem+json"
    assert response.json()["type"] == "urn:oop:ewbi:error:invalid-request"
    assert response.json()["invalidParams"]


def test_unknown_partner_is_404(client: TestClient) -> None:
@@ -116,6 +122,13 @@ def test_unknown_partner_is_404(client: TestClient) -> None:
    assert client.patch(f"{PARTNERS}/{uuid4()}", json={"name": "x"}).status_code == 404


def test_malformed_partner_id_is_problem_details(client: TestClient) -> None:
    response = client.get(f"{PARTNERS}/not-a-uuid")

    assert response.status_code == 400
    assert response.json()["invalidParams"][0]["param"] == "partner_op_id"


def test_patch_updates_only_given_fields(client: TestClient) -> None:
    partner = _register(client)

@@ -150,7 +163,7 @@ def test_patch_rejects_clearing_required_or_changing_identity(
) -> None:
    partner = _register(client)

    assert client.patch(f"{PARTNERS}/{partner['id']}", json=body).status_code == 422
    assert client.patch(f"{PARTNERS}/{partner['id']}", json=body).status_code == 400


def test_patch_to_a_client_id_held_by_another_partner_conflicts(client: TestClient) -> None:
Loading