Files
cyclone/backend/tests/test_api_validate_837.py
T

234 lines
7.5 KiB
Python

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