Files
Nora f25214189a 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.
2026-07-06 09:51:27 -06:00

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