234 lines
7.5 KiB
Python
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)
|