feat(sp40): POST /api/admin/validate-837 admin endpoint + 5 TestClient tests

This commit is contained in:
Nora
2026-07-07 18:15:19 -06:00
parent 7218ec76a8
commit 0815a4ad35
3 changed files with 290 additions and 2 deletions
+56 -2
View File
@@ -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 <file>` 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)
+1
View File
@@ -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,
+233
View File
@@ -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)