f25214189a
Server-side defense in depth for misroute ingest. Before SP35, posting an 835 file (or any other X12 with a parseable ISA envelope) to /api/parse-837 silently produced a BatchRecord with claims=[] and a bogus row on the History tab. The 837 parser only required an ISA envelope; it didn't check the ST transaction-set id. Two new guards run before persistence: 1. Envelope check: tokenize first, read ST01, reject anything that doesn't start with '837'. 400 with error='Mismatched file kind', expected='837p', detected_st=<actual>. Catches an 835/999/270/etc routed to the wrong endpoint. 2. Empty-claims check: even with the right envelope, if the parser produces zero CLM segments, return 400 'No claims parsed' and do NOT persist. New tests in tests/test_api.py: - test_parse_837_endpoint_rejects_835_input (was failing, now green) - test_parse_837_endpoint_rejects_empty_envelope (was failing, now green) - test_parse_837_endpoint_happy_path_still_works (regression guard) Helper _transaction_set_id_from_segments reused by the 835 mirror.
341 lines
14 KiB
Python
341 lines
14 KiB
Python
"""Tests for the FastAPI surface in ``cyclone.api``.
|
|
|
|
All tests use ``fastapi.testclient.TestClient`` — no real network or uvicorn
|
|
process is started. The fixture file is the same one used by the parser's
|
|
own end-to-end test (``co_medicaid_837p.txt`` → 2 claims, both pass).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from cyclone.api import app
|
|
from cyclone import __version__
|
|
|
|
|
|
FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
|
|
FIXTURE_835 = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt"
|
|
|
|
|
|
@pytest.fixture
|
|
def client() -> TestClient:
|
|
return TestClient(app)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Health
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_health_endpoint(client: TestClient):
|
|
"""SP19: health endpoint now returns a subsystem snapshot."""
|
|
resp = client.get("/api/health")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
# Old contract (status + version) is preserved.
|
|
assert body["status"] == "ok"
|
|
assert body["version"] == __version__
|
|
# SP19 additions.
|
|
assert "db" in body and body["db"].get("ok") is True
|
|
assert "scheduler" in body
|
|
assert "pubsub" in body
|
|
assert "batch" in body
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# JSON response path
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_parse_837_endpoint_returns_json(client: TestClient):
|
|
text = FIXTURE.read_text()
|
|
resp = client.post(
|
|
"/api/parse-837",
|
|
files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert "envelope" in body
|
|
assert body["envelope"] is not None
|
|
assert "claims" in body and len(body["claims"]) == 2
|
|
assert "summary" in body
|
|
assert body["summary"]["total_claims"] == 2
|
|
assert body["summary"]["passed"] == 2
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# NDJSON streaming path
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_parse_837_endpoint_streams_ndjson(client: TestClient):
|
|
text = FIXTURE.read_text()
|
|
resp = client.post(
|
|
"/api/parse-837",
|
|
files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.headers["content-type"].startswith("application/x-ndjson")
|
|
|
|
# Consume line-by-line (this is exactly what the React frontend will do).
|
|
lines = list(resp.iter_lines())
|
|
# 1 envelope + 2 claims + 1 summary
|
|
assert len(lines) == 4
|
|
|
|
parsed = [json.loads(line) for line in lines]
|
|
assert parsed[0]["type"] == "envelope"
|
|
assert parsed[0]["data"] is not None
|
|
assert parsed[1]["type"] == "claim"
|
|
assert parsed[2]["type"] == "claim"
|
|
assert parsed[3]["type"] == "summary"
|
|
|
|
# When include_raw_segments defaults to True, each claim carries raw segments.
|
|
for obj in parsed[1:3]:
|
|
assert "raw_segments" in obj["data"]
|
|
assert isinstance(obj["data"]["raw_segments"], list)
|
|
|
|
# Summary numbers match the JSON path.
|
|
assert parsed[3]["data"]["total_claims"] == 2
|
|
assert parsed[3]["data"]["passed"] == 2
|
|
# The streaming summary carries the server-side batch id so the
|
|
# frontend can call /api/batches/{id}/export-837 without a separate
|
|
# GET /api/batches round-trip (regression: it used to be missing
|
|
# on the stream path, only present on the JSON path, which made the
|
|
# Upload page's Export button say "no batch to export").
|
|
assert parsed[3]["type"] == "summary"
|
|
assert isinstance(parsed[3]["data"]["batch_id"], str)
|
|
assert len(parsed[3]["data"]["batch_id"]) == 32 # uuid4().hex
|
|
|
|
|
|
def test_parse_837_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
|
|
text = FIXTURE.read_text()
|
|
resp = client.post(
|
|
"/api/parse-837?include_raw_segments=false",
|
|
files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.headers["content-type"].startswith("application/x-ndjson")
|
|
|
|
claims = [json.loads(line) for line in resp.iter_lines() if json.loads(line)["type"] == "claim"]
|
|
assert len(claims) == 2
|
|
for c in claims:
|
|
assert c["data"]["raw_segments"] == []
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Validation / error paths
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_parse_837_endpoint_rejects_missing_file(client: TestClient):
|
|
# FastAPI's `File(...)` (no default) → 422 Unprocessable Entity.
|
|
resp = client.post("/api/parse-837")
|
|
assert resp.status_code == 422
|
|
|
|
|
|
def test_parse_837_endpoint_handles_payer_query_param(client: TestClient):
|
|
text = FIXTURE.read_text()
|
|
for payer in ("co_medicaid", "generic_837p"):
|
|
resp = client.post(
|
|
f"/api/parse-837?payer={payer}",
|
|
files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert resp.status_code == 200, (payer, resp.text)
|
|
body = resp.json()
|
|
assert body["summary"]["passed"] == 2
|
|
assert body["summary"]["total_claims"] == 2
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# CORS
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_cors_headers_present(client: TestClient):
|
|
# Simulate a preflight from the Vite dev origin.
|
|
resp = client.options(
|
|
"/api/parse-837",
|
|
headers={
|
|
"Origin": "http://localhost:5173",
|
|
"Access-Control-Request-Method": "POST",
|
|
"Access-Control-Request-Headers": "content-type",
|
|
},
|
|
)
|
|
assert resp.headers.get("access-control-allow-origin") == "http://localhost:5173"
|
|
assert "POST" in resp.headers.get("access-control-allow-methods", "").upper()
|
|
|
|
|
|
def test_cors_headers_present_for_loopback_ip(client: TestClient):
|
|
# ``http://127.0.0.1:5173`` is a distinct origin from
|
|
# ``http://localhost:5173`` per the CORS spec, even though both resolve
|
|
# to the same Vite dev server. Both must be allow-listed or tabs opened
|
|
# via the IP form silently break.
|
|
resp = client.options(
|
|
"/api/parse-837",
|
|
headers={
|
|
"Origin": "http://127.0.0.1:5173",
|
|
"Access-Control-Request-Method": "POST",
|
|
"Access-Control-Request-Headers": "content-type",
|
|
},
|
|
)
|
|
assert resp.headers.get("access-control-allow-origin") == "http://127.0.0.1:5173"
|
|
|
|
|
|
def test_cors_extra_origins_via_env(client: TestClient, monkeypatch):
|
|
# LAN / staging hosts opt in via CYCLONE_ALLOWED_ORIGINS. The env var
|
|
# is a comma-separated list; the middleware must reflect each entry.
|
|
# The allow-list is built at module import, so we re-execute the
|
|
# module under the env var and build a TestClient against the
|
|
# reloaded app.
|
|
monkeypatch.setenv(
|
|
"CYCLONE_ALLOWED_ORIGINS", "http://192.168.1.42:5173,https://staging.example.com"
|
|
)
|
|
import importlib
|
|
from cyclone import api as api_module
|
|
from fastapi.testclient import TestClient as _TC
|
|
importlib.reload(api_module)
|
|
try:
|
|
with _TC(api_module.app) as tc:
|
|
for origin in ("http://192.168.1.42:5173", "https://staging.example.com"):
|
|
resp = tc.options(
|
|
"/api/parse-837",
|
|
headers={
|
|
"Origin": origin,
|
|
"Access-Control-Request-Method": "POST",
|
|
"Access-Control-Request-Headers": "content-type",
|
|
},
|
|
)
|
|
assert resp.headers.get("access-control-allow-origin") == origin
|
|
finally:
|
|
monkeypatch.delenv("CYCLONE_ALLOWED_ORIGINS", raising=False)
|
|
# Reload once more so the module-level allow-list returns to its
|
|
# default for any test that imports `cyclone.api` after this one.
|
|
importlib.reload(api_module)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# SP35: parse-837 input guards (defense in depth against misroute ingest)
|
|
# --------------------------------------------------------------------------- #
|
|
#
|
|
# Before SP35, /api/parse-837 silently accepted any file that had a parseable
|
|
# ISA envelope. An 835 file dropped on the Upload page while the dropdown
|
|
# still said "837p" would land in the DB as an empty batch (claims=[]) and a
|
|
# bogus row on the History tab. SP35 fixes that at three layers:
|
|
#
|
|
# 1. Server envelope check: ST*837 (or ST*837P) required, else 400 with
|
|
# error="Mismatched file kind".
|
|
# 2. Server empty-claims check: even with the right envelope, if zero CLM
|
|
# segments were parsed, return 400 with error="No claims parsed"
|
|
# and DO NOT persist the batch.
|
|
# 3. UI auto-detect (separate file: src/pages/Upload.test.tsx).
|
|
#
|
|
# These tests are the server-layer regression locks. They run against the
|
|
# TestClient and use the existing fixtures. The 835 fixture has an ST*835
|
|
# envelope; posting it to /api/parse-837 must surface a 400 and must not
|
|
# create a BatchRecord.
|
|
|
|
|
|
def test_parse_837_endpoint_rejects_835_input(client: TestClient):
|
|
"""Uploading an 835 file to /api/parse-837 must fail loudly, not persist.
|
|
|
|
Repro for the original bug: user drops an 835 file on the Upload page
|
|
while the kind dropdown still says "837p" (Upload.tsx default). Before
|
|
SP35 the endpoint accepted it, ran it through the 837 parser (which
|
|
found zero CLM segments because the file has none), and persisted a
|
|
claims=[] batch — a bogus row on the History tab and on
|
|
/api/batches. SP35 closes the door at the server so the UI bug becomes
|
|
cosmetic instead of data-corrupting.
|
|
"""
|
|
text_835 = FIXTURE_835.read_text()
|
|
# Sanity check: the fixture really is an 835 file. If this ever flips,
|
|
# the test would still pass for the wrong reason.
|
|
assert "ST*835" in text_835, "fixture is no longer ST*835 — update SP35 tests"
|
|
|
|
# Snapshot the batch count BEFORE the bad upload so we can assert the
|
|
# request did NOT persist anything. Using the public /api/batches JSON
|
|
# endpoint (already exercised by the Dashboard).
|
|
before = client.get("/api/batches", headers={"Accept": "application/json"}).json()
|
|
total_before = before.get("total", len(before.get("items", [])))
|
|
|
|
resp = client.post(
|
|
"/api/parse-837",
|
|
files={"file": ("co_medicaid_835.txt", text_835, "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
|
|
assert resp.status_code == 400, resp.text
|
|
body = resp.json()
|
|
assert body["error"] == "Mismatched file kind"
|
|
assert body["expected"] == "837p"
|
|
assert body["detected_st"].startswith("835")
|
|
|
|
after = client.get("/api/batches", headers={"Accept": "application/json"}).json()
|
|
total_after = after.get("total", len(after.get("items", [])))
|
|
assert total_after == total_before, "Server persisted a batch from a 835 file"
|
|
|
|
|
|
def test_parse_837_endpoint_rejects_empty_envelope(client: TestClient):
|
|
"""Right envelope (ST*837), zero CLM segments → 400 No claims parsed.
|
|
|
|
Synthetic input: a complete ISA/GS/ST envelope with a BHT, a closing
|
|
SE/GE/IEA, and no CLM loops. The 837 parser will tokenize and build
|
|
the envelope cleanly, then return claims=[]. SP35 must surface this as
|
|
a 400 with error="No claims parsed" and must not persist a batch.
|
|
"""
|
|
# Bare 837 envelope — no HL/CLM loops. A real X12 file with ST*837
|
|
# but no claims is unusual but possible (e.g. a header-only test file
|
|
# or a truncated/cancelled run). The right behavior is to reject,
|
|
# not to silently persist an empty batch.
|
|
synthetic = (
|
|
"ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER "
|
|
"*260617*1937*^*00501*000000001*0*P*:~"
|
|
"GS*HC*SENDER*RECEIVER*20260706*1937*1*X*005010X222A1~"
|
|
"ST*837*0001*005010X222A1~"
|
|
"BHT*0019*00*0001*20260706*1937*CH~"
|
|
"SE*2*0001~"
|
|
"GE*1*1~"
|
|
"IEA*1*000000001~"
|
|
)
|
|
assert "ST*837" in synthetic
|
|
|
|
before = client.get("/api/batches", headers={"Accept": "application/json"}).json()
|
|
total_before = before.get("total", len(before.get("items", [])))
|
|
|
|
resp = client.post(
|
|
"/api/parse-837",
|
|
files={"file": ("empty_837.txt", synthetic, "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
|
|
assert resp.status_code == 400, resp.text
|
|
body = resp.json()
|
|
assert body["error"] == "No claims parsed"
|
|
|
|
after = client.get("/api/batches", headers={"Accept": "application/json"}).json()
|
|
total_after = after.get("total", len(after.get("items", [])))
|
|
assert total_after == total_before, "Server persisted an empty-claims batch"
|
|
|
|
|
|
def test_parse_837_endpoint_happy_path_still_works(client: TestClient):
|
|
"""Regression guard: real 837 fixture must still parse → 200 with claims.
|
|
|
|
Sits next to the new SP35 rejection tests so any future tightening of
|
|
the guards that accidentally blocks the happy path fails here loudly.
|
|
"""
|
|
text = FIXTURE.read_text()
|
|
resp = client.post(
|
|
"/api/parse-837",
|
|
files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body.get("summary", {}).get("total_claims", 0) >= 1
|
|
assert "batch_id" in body and len(body["batch_id"]) == 32
|