From 0815a4ad351721af2e433231aa0d0aec3eedaa9e Mon Sep 17 00:00:00 2001 From: Nora Date: Tue, 7 Jul 2026 18:15:19 -0600 Subject: [PATCH] feat(sp40): POST /api/admin/validate-837 admin endpoint + 5 TestClient tests --- backend/src/cyclone/api_routers/admin.py | 58 +++++- backend/src/cyclone/auth/permissions.py | 1 + backend/tests/test_api_validate_837.py | 233 +++++++++++++++++++++++ 3 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_api_validate_837.py diff --git a/backend/src/cyclone/api_routers/admin.py b/backend/src/cyclone/api_routers/admin.py index 386576d..ae17865 100644 --- a/backend/src/cyclone/api_routers/admin.py +++ b/backend/src/cyclone/api_routers/admin.py @@ -28,9 +28,10 @@ import threading import time from typing import Any -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from fastapi.responses import JSONResponse -from cyclone import db +from cyclone import db, edifabric from cyclone.audit_log import verify_chain from cyclone.auth.deps import matrix_gate from cyclone.clearhouse import InboundFile @@ -826,3 +827,56 @@ def reload_config(): except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) return {"ok": True, "loaded": len(configs), "errors": []} + + +# --------------------------------------------------------------------------- +# SP40: POST /api/admin/validate-837 +# +# Admin-only Edifabric validation probe: upload an 837P file via +# multipart, hit /v2/x12/read → /v2/x12/validate, return the raw +# OperationResult JSON (Status, Details, LastIndex). Mirrors the +# `cyclone validate-837 ` CLI behavior — the wire shape comes +# straight through so callers don't have to translate between the two. +# +# HTTP status codes: +# 200 — OperationResult returned (Status may be success / warning / +# error; the caller decides whether the file is acceptable). +# 400 — uploaded file is empty / undecodable (defense-in-depth, same +# shape as /api/parse-837). +# 502 — Edifabric upstream 4xx/5xx — caller can surface the body. +# --------------------------------------------------------------------------- + + +@router.post("/api/admin/validate-837") +async def validate_837_endpoint( + file: UploadFile = File(...), +) -> Any: + """Validate an uploaded 837P file via Edifabric /v2/x12/validate. + + Multipart upload (``file=...``); the file is read into bytes and + passed to :func:`cyclone.edifabric.validate_edi`. The API key is + resolved server-side from ``cyclone.secrets.get_secret('edifabric.api_key')`` + so the key never leaves the backend. + """ + raw = await file.read() + if not raw: + return JSONResponse( + status_code=400, + content={"error": "Empty file", "detail": "Uploaded file contained no bytes."}, + ) + try: + result = edifabric.validate_edi(raw) + except edifabric.EdifabricError as exc: + # status_code=0 is a client-side config problem (missing API + # key); 4xx/5xx upstream become 502. Surface the Edifabric body + # verbatim so the operator can see what went wrong. + http_status = 502 if exc.status_code else 503 + return JSONResponse( + status_code=http_status, + content={ + "error": "Edifabric validation failed", + "upstream_status": exc.status_code, + "upstream_body": exc.body, + }, + ) + return JSONResponse(content=result) diff --git a/backend/src/cyclone/auth/permissions.py b/backend/src/cyclone/auth/permissions.py index da83741..aac62a0 100644 --- a/backend/src/cyclone/auth/permissions.py +++ b/backend/src/cyclone/auth/permissions.py @@ -68,6 +68,7 @@ PERMISSIONS: dict[tuple[str, str], set[Role]] = { ("POST", "/api/admin/db/rotate-key"): ADMIN_ONLY, ("POST", "/api/admin/reload-config"): ADMIN_ONLY, ("GET", "/api/admin/validate-provider"): ADMIN_ONLY, + ("POST", "/api/admin/validate-837"): ADMIN_ONLY, # SP40: Edifabric validation probe # Write endpoints (admin + user, no viewer). ("POST", "/api/parse-837"): WRITE_ROLES, diff --git a/backend/tests/test_api_validate_837.py b/backend/tests/test_api_validate_837.py new file mode 100644 index 0000000..9271388 --- /dev/null +++ b/backend/tests/test_api_validate_837.py @@ -0,0 +1,233 @@ +"""SP40: tests for ``POST /api/admin/validate-837`` (admin-gated). + +Covers: + +1. Auth gate — 401 with no session cookie (matrix_gate fires when + AUTH_DISABLED is flipped off). +2. Happy path — admin-cookie request with a multipart file upload + returns the Edifabric OperationResult JSON. +3. Missing file part — FastAPI's built-in validation rejects requests + without the ``file`` form part with 422. +""" +from __future__ import annotations + +import httpx +import pytest +from fastapi.testclient import TestClient + +from cyclone import edifabric +from cyclone.api import app + + +_TEST_KEY = "test-edifabric-key-0123456789abcdef" + + +def _make_client(handler): + transport = httpx.MockTransport(handler) + return httpx.Client(transport=transport, timeout=10.0) + + +def _install(handler): + edifabric.set_transport_factory(lambda: _make_client(handler)) + + +@pytest.fixture +def client(): + return TestClient(app) + + +@pytest.fixture(autouse=True) +def _reset_transport(): + yield + edifabric._reset_transport_factory() + import cyclone.secrets + cyclone.secrets.get_secret = _real_get_secret # type: ignore[assignment] + + +import cyclone.secrets as _secrets_module # noqa: E402 +_real_get_secret = _secrets_module.get_secret # noqa: E402 + + +def _mock_secrets(patched_key: str): + """Install a get_secret mock that returns ``patched_key`` for any + 'edifabric.api_key' lookup. Restores the real function on + teardown via the autouse ``_reset_transport`` fixture. + + Until SP40 Task 6 lands, ``_ENV_NAME_FOR`` doesn't know about + 'edifabric.api_key', so the env-var name expected is the same — + but the more robust pattern is to monkeypatch at the function + boundary. That keeps test behavior independent of the secrets + table layout. + """ + def _fake(name: str): + if name == "edifabric.api_key": + return patched_key + return _real_get_secret(name) + + _secrets_module.get_secret = _fake # type: ignore[assignment] + return _fake + + +@pytest.fixture +def mock_edifabric_ok(): + """Mock Edifabric to return a clean OperationResult on /validate. + + Wires both the transport mock and the secrets.get_secret monkeypatch + so ``cyclone.edifabric.validate_edi(...)`` resolves an API key. + """ + operation_result = { + "Status": "success", + "Details": [], + "LastIndex": 46, + } + x12 = { + "SegmentDelimiter": "~", + "DataElementDelimiter": "*", + "ISA": {"InterchangeControlNumber_13": "000000001"}, + "Groups": [], + "IEATrailers": [], + } + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/read"): + return httpx.Response(200, json=[x12]) + if request.url.path.endswith("/validate"): + return httpx.Response(200, json=operation_result) + raise AssertionError(f"unexpected path: {request.url.path}") + + _mock_secrets(_TEST_KEY) + _install(handler) + + +# --------------------------------------------------------------------------- # +# Auth gate +# --------------------------------------------------------------------------- # + + +def test_validate_837_no_session_returns_401(client, tmp_path, monkeypatch): + """No session cookie + AUTH_DISABLED off → 401 from matrix_gate. + + Mirrors the test pattern in test_api_clearhouse_patch.py:99 — we + flip AUTH_DISABLED to False inside the test (monkeypatch restores + it at teardown) so the gate fires. + """ + import cyclone.auth.deps as auth_deps + + monkeypatch.setattr(auth_deps, "AUTH_DISABLED", False) + + sample = tmp_path / "ok.x12" + sample.write_text("ISA*seg~SE*1*0001~IEA*0*000000001~") + + with sample.open("rb") as fh: + resp = client.post( + "/api/admin/validate-837", + files={"file": ("ok.x12", fh, "application/octet-stream")}, + ) + assert resp.status_code == 401, resp.text + + +# --------------------------------------------------------------------------- # +# Happy path +# --------------------------------------------------------------------------- # + + +def test_validate_837_returns_operation_result(client, tmp_path, mock_edifabric_ok): + """Admin request with multipart file returns the OperationResult verbatim. + + AUTH_DISABLED is True (set by conftest's autouse fixture), so the + admin gate fires-and-passes; the Edifabric client is mocked to + return a clean Success. + """ + fixture = tmp_path / "ok.x12" + fixture.write_text("ISA*00*...~IEA*0*000000001~") + + with fixture.open("rb") as fh: + resp = client.post( + "/api/admin/validate-837", + files={"file": ("ok.x12", fh, "application/octet-stream")}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["Status"] == "success" + assert body["Details"] == [] + assert body["LastIndex"] == 46 + + +def test_validate_837_returns_error_details(client, tmp_path): + """A 200 response with Status='error' + Details passes through verbatim. + + The OperationResult is data; the HTTP response is 200 (the + endpoint did its job of talking to Edifabric). Callers inspect + Status in the body to decide whether to fail-closed. + """ + error_payload = { + "Status": "error", + "Details": [ + {"SegmentId": "PER", "Message": "PER-04 is required", "Status": "error"}, + {"SegmentId": "SBR", "Message": "SBR-09 is required", "Status": "error"}, + ], + "LastIndex": 5, + } + x12 = { + "SegmentDelimiter": "~", + "DataElementDelimiter": "*", + "ISA": {"InterchangeControlNumber_13": "000000001"}, + "Groups": [], + "IEATrailers": [], + } + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/read"): + return httpx.Response(200, json=[x12]) + if request.url.path.endswith("/validate"): + return httpx.Response(200, json=error_payload) + raise AssertionError(f"unexpected path: {request.url.path}") + + _mock_secrets(_TEST_KEY) + _install(handler) + + fixture = tmp_path / "bad.x12" + fixture.write_text("ISA*bad~") + + with fixture.open("rb") as fh: + resp = client.post( + "/api/admin/validate-837", + files={"file": ("bad.x12", fh, "application/octet-stream")}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["Status"] == "error" + assert len(body["Details"]) == 2 + assert body["Details"][0]["Message"] == "PER-04 is required" + + +def test_validate_837_missing_file_returns_422(client): + """No ``file`` form part → FastAPI returns 422 (built-in validation). + + This is the FastAPI-default behavior for ``UploadFile = File(...)`` + when the field is absent. The endpoint body itself never runs. + """ + resp = client.post("/api/admin/validate-837") + assert resp.status_code == 422, resp.text + + +def test_validate_837_missing_api_key_returns_503(client, tmp_path, monkeypatch): + """With no API key configured, the endpoint surfaces a 503 with the + 'API key not configured' detail (status_code 0 → 503, not 502). + + Upstream 4xx/5xx map to 502; a 0-status_code EdifabricError means + the client-side config is missing and the caller can fix it. + """ + monkeypatch.delenv("CYCLONE_EDIFABRIC_API_KEY", raising=False) + + fixture = tmp_path / "ok.x12" + fixture.write_text("ISA*seg~") + + with fixture.open("rb") as fh: + resp = client.post( + "/api/admin/validate-837", + files={"file": ("ok.x12", fh, "application/octet-stream")}, + ) + assert resp.status_code == 503, resp.text + body = resp.json() + assert "API key not configured" in str(body)