diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 000bc8c..7a6d80a 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -671,6 +671,31 @@ async def parse_835_endpoint( config = _resolve_payer_835(payer) + # SP35 guard 1: envelope check. Mirrors the parse-837 path: tokenize, + # read ST01, reject anything that doesn't start with "835". Same + # defense-in-depth rationale — the UI auto-detect (src/pages/Upload.tsx) + # is layer A, but server-side guards protect every API caller. + try: + _segments_835 = _tokenize_segments(text) + detected_st_835 = _transaction_set_id_from_segments(_segments_835) or "" + except CycloneParseError: + detected_st_835 = "" + + if detected_st_835 and not detected_st_835.upper().startswith("835"): + return JSONResponse( + status_code=400, + content={ + "error": "Mismatched file kind", + "expected": "835", + "detected_st": detected_st_835, + "detail": ( + f"File declares ST*{detected_st_835}* but this endpoint " + f"expects ST*835*. Pick the matching endpoint on the " + f"Upload page (or let auto-detect choose for you)." + ), + }, + ) + try: result = parse_835(text, config, input_file=file.filename or "") except CycloneParseError as exc: @@ -685,6 +710,21 @@ async def parse_835_endpoint( content={"error": "Internal server error", "detail": str(exc)}, ) + # SP35 guard 2: empty-claims check. Same as parse-837: a BatchRecord + # with claims=[] is never a valid production 835 batch and we refuse + # to persist it. + if not result.claims: + return JSONResponse( + status_code=400, + content={ + "error": "No claims parsed", + "detail": ( + "The file passed the envelope check but contained no " + "CLP segments. Refusing to persist an empty batch." + ), + }, + ) + # Always run the validator; attach the report so the JSON path can # surface it and the NDJSON path can fold the counts into the summary. # 835 validation is batch-level, so pass/fail applies uniformly to every diff --git a/backend/tests/test_api_835.py b/backend/tests/test_api_835.py index bc3602e..60c03d9 100644 --- a/backend/tests/test_api_835.py +++ b/backend/tests/test_api_835.py @@ -13,6 +13,7 @@ from cyclone.store import store as global_store FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt" UNBALANCED = Path(__file__).parent / "fixtures" / "unbalanced_835.txt" +FIXTURE_837P = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt" @pytest.fixture @@ -337,3 +338,117 @@ def test_prodfile_round_trip_persists_separately(client: TestClient): # No duplicate PCNs survived the dedup; sanity check on persistence. pcns = [r["claimId"] for r in all_remits if r["claimId"]] assert len(pcns) == len(set(pcns)), "duplicate PCN across batches would be a persistence bug" + + +# --------------------------------------------------------------------------- # +# SP35: parse-835 input guards (mirror of the parse-837 guards) +# --------------------------------------------------------------------------- # +# +# Before SP35, /api/parse-835 had the same silent-corruption shape as +# /api/parse-837: any file with a parseable ISA envelope was accepted, and +# the 835 parser returned claims=[] when the file had no CLP segments. +# This produced empty 835 batches and bogus rows on the History tab. +# These tests are the server-layer regression locks for the 835 endpoint. + + +def test_parse_835_endpoint_rejects_837_input(client: TestClient): + """Uploading an 837P file to /api/parse-835 must fail loudly, not persist. + + Repro for the symmetric bug: user drops an 837P file on the Upload page + while the dropdown still says "835" (Upload.tsx default). Before SP35 the + endpoint accepted it, ran it through the 835 parser (which found zero + CLP segments), and persisted an empty claims=[] batch. SP35 closes the + door at the server so the UI bug becomes cosmetic instead of + data-corrupting. + """ + text_837p = FIXTURE_837P.read_text() + # Sanity check: the fixture really is an 837P file. If this ever flips, + # the test would still pass for the wrong reason. + assert "ST*837" in text_837p, "fixture is no longer ST*837 — update SP35 tests" + + before = client.get("/api/batches", headers={"Accept": "application/json"}).json() + total_before = before.get("total", len(before.get("items", []))) + + resp = client.post( + "/api/parse-835", + files={"file": ("co_medicaid_837p.txt", text_837p, "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"] == "835" + assert body["detected_st"].startswith("837") + + 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 837P file" + + +def test_parse_835_endpoint_rejects_empty_envelope(client: TestClient): + """Right envelope (ST*835), zero CLP segments → 400 No claims parsed. + + Synthetic input: a complete ISA/GS/ST envelope with a BPR + TRN, a + closing SE/GE/IEA, and no CLP loops. The 835 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 835 envelope — no LX/CLP loops. A real X12 835 with no claims + # is unusual but possible (header-only test file or a cancelled run). + # The right behavior is to reject, not to silently persist. + synthetic = ( + "ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " + "*260617*1937*^*00501*000000001*0*P*:~" + "GS*HP*SENDER*RECEIVER*20260706*1937*1*X*005010X221A1~" + "ST*835*0001~" + "BPR*I*100.00*C*ACH*CCP*01*021000021*DA*123456*1512345678**01*021000021*DA*123456*20260706~" + "TRN*1*0001*1512345678~" + "N1*PR*PAYER NAME~" + "N3*123 PAYER ST~" + "N4*DENVER*CO*80202~" + "PER*BL*MEMBER SERVICES*TE*8005551212~" + "N1*PE*PAYEE NAME~" + "N3*456 PAYEE ST~" + "N4*DENVER*CO*80202~" + "REF*TJ*123456789~" + "SE*9*0001~" + "GE*1*1~" + "IEA*1*000000001~" + ) + assert "ST*835" 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-835", + files={"file": ("empty_835.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 835 batch" + + +def test_parse_835_endpoint_happy_path_still_works(client: TestClient): + """Regression guard: real 835 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-835", + files={"file": ("co_medicaid_835.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