"""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)