feat(sp40): POST /api/admin/validate-837 admin endpoint + 5 TestClient tests
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user