feat(sp35): /api/parse-837 envelope + empty-claims guards
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.
This commit is contained in:
@@ -18,6 +18,7 @@ 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
|
||||
@@ -216,3 +217,124 @@ def test_cors_extra_origins_via_env(client: TestClient, monkeypatch):
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user