merge: SP40 Edifabric validation gate + 837P byte-defect sweep into main

This commit is contained in:
Nora
2026-07-07 18:29:37 -06:00
15 changed files with 1581 additions and 4 deletions
+56 -2
View File
@@ -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)
+1
View File
@@ -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,
+125 -1
View File
@@ -297,6 +297,71 @@ def validate_tax_id_cmd(tax_id: str, log_level: str) -> None:
sys.exit(1)
# ---------------------------------------------------------------------------
# SP40: `cyclone validate-837 <file>`
#
# Sends the file to EdiFabric's /v2/x12/validate endpoint via the
# cyclone.edifabric HTTP client. Prints the OperationResult and exits:
# 0 — Status success (or warning-only)
# 2 — Status error (one or more Details items have Status="error")
# 1 — unexpected exception (network / 5xx / missing API key)
#
# Exit code contract mirrors the rest of the validator family
# (validate-npi, validate-tax-id): 0 valid, 1/2 invalid, file-level
# failures use 2.
# ---------------------------------------------------------------------------
@main.command("validate-837")
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--api-key", default=None, help="Override the Edifabric API key (else read from CYCLONE_EDIFABRIC_API_KEY or keychain).")
@click.option("--log-level", default="WARNING", show_default=True, type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]))
def validate_837_cmd(input_file: Path, api_key: str | None, log_level: str) -> None:
"""Validate an 837P file via EdiFabric /v2/x12/validate (SP40).
Sends the file to the Edifabric reference parser and prints the
OperationResult. Exits 0 on success or warning-only, 2 on any
error in Details. Use --api-key to override the key (else the
CLI reads from secrets per the SP40 spec).
"""
setup_logging(level=log_level)
from cyclone import edifabric
edi_bytes = input_file.read_bytes()
try:
result = edifabric.validate_edi(edi_bytes, api_key=api_key)
except edifabric.EdifabricError as exc:
click.echo(f"VALIDATION ERROR: {exc}", err=True)
if exc.status_code == 0:
# Missing API key / config — exit 1 (unexpected, fixable).
sys.exit(1)
# HTTP failure from Edifabric — exit 2 (file-level / upstream).
sys.exit(2)
except Exception as exc: # noqa: BLE001
click.echo(f"UNEXPECTED ERROR: {type(exc).__name__}: {exc}", err=True)
sys.exit(1)
status = result.get("Status", "unknown")
details = result.get("Details") or []
last_index = result.get("LastIndex", "?")
# Print the OperationResult summary.
click.echo(f"Status: {status}")
click.echo(f"LastIndex: {last_index}")
click.echo(f"Details: {len(details)} item(s)")
for d in details:
seg = d.get("SegmentId", "?")
msg = d.get("Message", "")
dstatus = d.get("Status", "?")
click.echo(f" [{dstatus}] {seg}: {msg}")
if status == "error":
click.echo(f"VALIDATION FAILED: {len(details)} error(s) in {input_file.name}", err=True)
sys.exit(2)
# success or warning — both exit 0 (warnings don't fail-closed).
return
# ---------------------------------------------------------------------------
# SP32: `cyclone backfill-rendering-npi` (Task 6)
#
@@ -545,6 +610,9 @@ def backfill_999_rejections(dry_run: bool, actor: str) -> None:
help="Audit-log actor tag for the clearhouse.submitted events.")
@click.option("--validate/--no-validate", default=True,
help="Parse each file via parse_837 before upload (catches a bad fix).")
@click.option("--skip-edifabric-validate/--no-skip-edifabric-validate", default=False,
help="Skip the SP40 Edifabric pre-upload validation gate. "
"Default: gate runs (any Status='error' aborts the file).")
@click.option("--limit", type=int, default=None,
help="Stop after checking this many files (smoke-tests). Counts "
"all attempts, not just successful uploads.")
@@ -554,6 +622,7 @@ def resubmit_rejected_claims(
ingest_dir: str,
actor: str,
validate: bool,
skip_edifabric_validate: bool,
limit: int | None,
reconnect_every: int,
) -> None:
@@ -612,6 +681,8 @@ def resubmit_rejected_claims(
skipped = 0
failed = 0
validated = 0
validate_failed = 0 # SP40: Edifabric gate aborted these
validate_failures: list[tuple[str, list[dict]]] = [] # (filename, [Details...])
payer_cfg = PayerConfig.co_medicaid()
start = time.monotonic()
last_progress = start
@@ -676,6 +747,44 @@ def resubmit_rejected_claims(
continue
validated += 1
# SP40: Edifabric pre-upload gate. Fails closed on any
# ``Status == "error"`` item — the file is recorded as
# ``validate_failed`` and the batch continues with the other
# files. Bypass with ``--skip-edifabric-validate`` (dev only).
if not skip_edifabric_validate:
from cyclone import edifabric
try:
op_result = edifabric.validate_edi(content)
except edifabric.EdifabricError as exc:
# Edifabric upstream failure (5xx / 4xx / config) —
# treat as a hard fail for THIS file but don't stop
# the batch. Operator should inspect.
validate_failed += 1
validate_failures.append((src.name, [
{"SegmentId": "?", "Message": f"edifabric: {exc}", "Status": "error"},
]))
click.echo(
f"EDIFABRIC FAIL {src.name}: {exc.__class__.__name__}: {exc}",
err=True,
)
continue
if op_result.get("Status") == "error":
validate_failed += 1
details = op_result.get("Details") or []
validate_failures.append((src.name, details))
first = details[:5]
click.echo(
f"EDIFABRIC VALIDATION FAIL {src.name}: {len(details)} error(s)",
err=True,
)
for d in first:
seg = d.get("SegmentId", "?")
msg = d.get("Message", "")
click.echo(f" [{d.get('Status', '?')}] {seg}: {msg}", err=True)
if len(details) > len(first):
click.echo(f" ... and {len(details) - len(first)} more", err=True)
continue
local_size = len(content)
remote_path = f"{remote_root}/{src.name}"
@@ -805,9 +914,24 @@ def resubmit_rejected_claims(
elapsed = time.monotonic() - start
click.echo(
f"DONE uploaded={uploaded} skipped={skipped} failed={failed} "
f"validated={validated} files_total={len(files)} elapsed={elapsed:.1f}s"
f"validated={validated} validate_failed={validate_failed} "
f"files_total={len(files)} elapsed={elapsed:.1f}s"
)
# SP40: print Edifabric validation failures as a group so the
# operator can fix and re-run. One block, sorted by file name.
if validate_failures:
click.echo(
f"\n{len(validate_failures)} file(s) blocked by Edifabric gate:",
err=True,
)
for name, details in sorted(validate_failures):
click.echo(f" {name}", err=True)
for d in details:
seg = d.get("SegmentId", "?")
msg = d.get("Message", "")
click.echo(f" [{d.get('Status', '?')}] {seg}: {msg}", err=True)
@main.command("submit-batch")
@click.option("--ingest-dir", default="ingest",
+216
View File
@@ -0,0 +1,216 @@
"""SP40: thin HTTP client for the EdiNation / Edifabric validation API.
Wraps the two-step /v2/x12/read (raw EDI → ``X12Interchange`` JSON) and
/v2/x12/validate (``X12Interchange`` JSON → ``OperationResult``) flow.
Cyclone has no other outbound HTTP today; this is the first such
client. ``httpx`` is already a project dep (used by the test suite)
so we don't introduce a new dependency.
Public surface:
- :func:`read_interchange` — POST raw EDI bytes to /x12/read.
- :func:`validate_interchange` — POST ``X12Interchange`` JSON to /x12/validate.
- :func:`validate_edi` — composes the two; this is what the CLI and
pre-upload gate use.
- :class:`EdifabricError` — raised on 4xx/5xx so callers can surface
the Edifabric error verbatim (the body is a dict or string).
The API key is taken from :func:`cyclone.secrets.get_secret('edifabric.api_key')`
which maps to ``CYCLONE_EDIFABRIC_API_KEY`` env var or macOS Keychain.
Tests inject a canned key (and a mocked transport) via the factory
hook :func:`set_transport_factory` so no live HTTP hits the network.
The endpoint contract (from the EdiNation API reference,
``https://support.edifabric.com/hc/en-us/sections/360005605638``):
- ``POST https://api.edination.com/v2/x12/read``
- Headers: ``Ocp-Apim-Subscription-Key: <key>``, ``Content-Type: application/octet-stream``
- Body: raw EDI bytes
- Response (200): JSON array of ``X12Interchange`` objects (cyclone always sends one interchange)
- ``POST https://api.edination.com/v2/x12/validate``
- Headers: ``Ocp-Apim-Subscription-Key: <key>``, ``Content-Type: application/json``
- Body: one ``X12Interchange`` object
- Response (200): ``OperationResult`` (``Status`` ∈ {``"success"``, ``"warning"``, ``"error"``}, ``Details`` array)
We treat any non-2xx as an :class:`EdifabricError`. The response body
is preserved verbatim so callers can inspect it.
"""
from __future__ import annotations
import logging
import os
from typing import Any, Callable
import httpx
_log = logging.getLogger(__name__)
_BASE_URL = "https://api.edination.com/v2/x12"
_READ_PATH = "/read"
_VALIDATE_PATH = "/validate"
# Subscription-key header name (Azure API Management convention).
_SUB_HEADER = "Ocp-Apim-Subscription-Key"
class EdifabricError(RuntimeError):
"""Raised when the Edifabric API returns a non-2xx response.
Attributes:
status_code: HTTP status code returned by Edifabric.
body: The response body — usually a dict with ``error`` /
``message`` keys for 4xx, or a string for 5xx / network
errors. Preserved verbatim so the caller can surface it
to the operator.
"""
def __init__(self, status_code: int, body: Any) -> None:
self.status_code = status_code
self.body = body
body_repr = repr(body) if not isinstance(body, str) else body
msg = f"Edifabric API returned {status_code}: {body_repr}"
super().__init__(msg)
# --- Transport injection (test seam) -----------------------------------
# Default transport factory builds a normal httpx.Client. Tests can
# call set_transport_factory() with a callable that returns a Client
# backed by httpx.MockTransport (no live HTTP).
_transport_factory: Callable[[], httpx.Client] = lambda: httpx.Client(timeout=30.0)
def set_transport_factory(factory: Callable[[], httpx.Client]) -> None:
"""Inject an ``httpx.Client`` factory for tests.
The factory must return an ``httpx.Client`` whose ``transport`` is
a mock (e.g. ``httpx.MockTransport(handler)``) so no real HTTP is
performed. Returns the previous factory so tests can restore it.
"""
global _transport_factory
prev = _transport_factory
_transport_factory = factory
return prev # type: ignore[return-value]
def _reset_transport_factory() -> None:
"""Restore the default transport factory (called in test cleanup)."""
global _transport_factory
_transport_factory = lambda: httpx.Client(timeout=30.0)
# --- Public surface ----------------------------------------------------
def _get_api_key() -> str:
"""Resolve the Edifabric API key.
Looks up ``CYCLONE_EDIFABRIC_API_KEY`` (or the keychain entry
``cyclone / edifabric.api_key``). The secrets module is imported
lazily so test setups that mock it can do so before first use.
"""
from cyclone.secrets import get_secret
key = get_secret("edifabric.api_key")
if not key:
raise EdifabricError(
0,
"Edifabric API key not configured; set CYCLONE_EDIFABRIC_API_KEY "
"env var or run `cyclone secrets set edifabric.api_key <key>`",
)
return key
def read_interchange(edi_bytes: bytes, *, api_key: str | None = None) -> dict:
"""POST raw EDI bytes to /x12/read and return the first X12Interchange.
The /x12/read endpoint accepts a multi-interchange file and returns
an array. Cyclone only ever sends single interchanges, so we return
the first (and only) element. If the file contains multiple
interchanges, callers should call ``validate_interchange`` on each.
"""
if not isinstance(edi_bytes, (bytes, bytearray)):
raise TypeError(
f"edi_bytes must be bytes, got {type(edi_bytes).__name__}"
)
key = api_key if api_key is not None else _get_api_key()
headers = {
_SUB_HEADER: key,
"Content-Type": "application/octet-stream",
}
with _transport_factory() as client:
resp = client.post(
f"{_BASE_URL}{_READ_PATH}",
content=bytes(edi_bytes),
headers=headers,
)
if not (200 <= resp.status_code < 300):
raise EdifabricError(resp.status_code, _safe_body(resp))
data = resp.json()
if not isinstance(data, list) or not data:
raise EdifabricError(
502,
f"unexpected /x12/read response shape: expected non-empty list, "
f"got {type(data).__name__} of length {len(data) if hasattr(data, '__len__') else '?'}",
)
return data[0]
def validate_interchange(x12_json: dict, *, api_key: str | None = None) -> dict:
"""POST an X12Interchange JSON to /x12/validate and return the OperationResult.
The OperationResult schema (per the EdiNation docs):
- ``Status`` — ``"success"`` / ``"warning"`` / ``"error"``.
- ``Details`` — array of ``{Index, SegmentId, Value, Message, Status, ...}``.
- ``LastIndex`` — 1-based index of the last processed segment.
We do NOT raise on ``Status == "error"`` — the caller decides whether
to fail-closed (the pre-upload gate does; the CLI prints and exits
with the appropriate code). Non-2xx HTTP responses DO raise
:class:`EdifabricError`.
"""
if not isinstance(x12_json, dict):
raise TypeError(
f"x12_json must be a dict, got {type(x12_json).__name__}"
)
key = api_key if api_key is not None else _get_api_key()
headers = {
_SUB_HEADER: key,
"Content-Type": "application/json",
}
with _transport_factory() as client:
resp = client.post(
f"{_BASE_URL}{_VALIDATE_PATH}",
json=x12_json,
headers=headers,
)
if not (200 <= resp.status_code < 300):
raise EdifabricError(resp.status_code, _safe_body(resp))
return resp.json()
def validate_edi(edi_bytes: bytes, *, api_key: str | None = None) -> dict:
"""Two-step convenience: read → validate. Returns the OperationResult.
This is what ``cyclone validate-837 <file>`` and the pre-upload
gate in ``resubmit-rejected-claims`` call. Raises
:class:`EdifabricError` on transport / non-2xx errors. The
OperationResult is returned verbatim so the caller can inspect
``Status`` and ``Details`` themselves.
"""
x12 = read_interchange(edi_bytes, api_key=api_key)
return validate_interchange(x12, api_key=api_key)
# --- Internal helpers --------------------------------------------------
def _safe_body(resp: httpx.Response) -> Any:
"""Return the response body, preferring JSON when possible."""
try:
return resp.json()
except Exception: # noqa: BLE001
text = resp.text
return text if text else f"<empty {resp.status_code} response>"
@@ -420,6 +420,17 @@ def _build_submitter_block(
contact_email: str | None = None,
email_qual: str = "EM",
) -> list[str]:
# SP40: PER-02 (Name) and at least one PER-03/04 pair are required
# by Edifabric's x12/validate — emitting only PER-01 ("IC") makes
# the file invalid. Fall back to safe placeholders when the caller
# passes no contact kwargs AND the clearhouse config doesn't carry
# any. Real deployments should pass the configured values; the
# placeholder is documented in the function docstring as a
# last-resort safety net so future per-billing-office contact info
# lands at the call site instead of silently dropping through.
if not any([contact_name, contact_phone, contact_email]):
contact_name = "CUSTOMER SERVICE"
contact_phone = "8005550100"
out = [
_build_nm1("41", "41", submitter_name or sender_id, "46", sender_id),
]
@@ -460,6 +471,14 @@ def _build_subscriber_block(
claim_filing_indicator_code: str | None,
) -> list[str]:
"""HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children."""
# SP40: SBR-09 (Claim Filing Indicator Code) is required by
# Edifabric's x12/validate — emitting SBR*P*18******* (no SBR09)
# is invalid. Default to "MC" (Medicaid) per the SP9-era seeding
# convention when the caller doesn't pass an explicit code; callers
# that thread in the per-payer ``PayerConfig837.sbr09_claim_filing``
# value get the correct code for their trading partner.
if not claim_filing_indicator_code:
claim_filing_indicator_code = "MC"
out = [
_build_hl("2", "1", "22", "0"), # HL*2 — subscriber, 0 children
_build_sbr("18", claim_filing_indicator_code),
+6
View File
@@ -155,4 +155,10 @@ def has_keyring() -> bool:
# ``CYCLONE_SFTP_PASSWORD`` (env var).
_ENV_NAME_FOR: dict[str, str] = {
"sftp.gainwell.password": "CYCLONE_SFTP_PASSWORD",
# SP40: Edifabric /v2/x12/validate API key. Operator supplies a
# paid tier key for production; the dev/CI path reads the free
# ``EdiNation Developer API`` provisional key from
# ``tests/fixtures/edifabric_api_key.txt`` (gitignored copy in
# production deployments — see Task 6 plan).
"edifabric.api_key": "CYCLONE_EDIFABRIC_API_KEY",
}
+16
View File
@@ -0,0 +1,16 @@
# SP40: dev-only public eval key for the EdiNation / Edifabric
# /v2/x12/validate reference parser. This key has hit the public
# evaluation docs and is documented as a "developer API" provisional
# key — fine for local CI / smoke tests, never ship to production.
#
# Production deployments MUST override via the secrets layer:
#
# cyclone secrets set edifabric.api_key <paid-tier-key>
# # or
# export CYCLONE_EDIFABRIC_API_KEY_FILE=/path/to/paid-key.txt
#
# The fixture is referenced by name so the URL-reminder route survives
# refactors; the actual key isn't sent over the wire because every
# test patches edifabric.set_transport_factory() and secrets.get_secret
# before invoke.
3ecf6b1c5cf34bd797a5f4c57951a1cf
+233
View File
@@ -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)
+188
View File
@@ -0,0 +1,188 @@
"""SP40: tests for the cyclone.edifabric HTTP client.
All tests use httpx.MockTransport — no live HTTP hits the network.
The API key is supplied directly via the ``api_key=`` kwarg so the
secrets module is never read during tests.
"""
from __future__ import annotations
import json
import httpx
import pytest
from cyclone import edifabric
_TEST_KEY = "test-edifabric-key-0123456789abcdef"
def _make_client(handler):
"""Build an httpx.Client whose transport is the given handler."""
transport = httpx.MockTransport(handler)
return httpx.Client(transport=transport, timeout=10.0)
def _install_factory(handler):
"""Swap in the mocked httpx.Client for the duration of a test."""
return edifabric.set_transport_factory(lambda: _make_client(handler))
@pytest.fixture(autouse=True)
def _reset_transport():
"""Restore the default transport after each test (so a failing test
can't poison the next)."""
yield
edifabric._reset_transport_factory()
# --- /x12/read ---------------------------------------------------------
def test_read_interchange_returns_first_x12_from_array():
"""The /x12/read endpoint returns a list (multi-interchange file).
Cyclone calls return the first element."""
x12 = {
"SegmentDelimiter": "~",
"DataElementDelimiter": "*",
"ISA": {"InterchangeControlNumber_13": "000000001"},
"Groups": [],
"IEATrailers": [],
}
def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["Ocp-Apim-Subscription-Key"] == _TEST_KEY
assert request.headers["Content-Type"] == "application/octet-stream"
return httpx.Response(200, json=[x12])
_install_factory(handler)
result = edifabric.read_interchange(b"ISA*...~IEA*0*000000001~", api_key=_TEST_KEY)
assert result["ISA"]["InterchangeControlNumber_13"] == "000000001"
def test_read_interchange_rejects_empty_response():
"""If /x12/read returns an empty list, surface a 502-style error."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=[])
_install_factory(handler)
with pytest.raises(edifabric.EdifabricError) as exc_info:
edifabric.read_interchange(b"ISA*...~IEA*0*000000001~", api_key=_TEST_KEY)
assert exc_info.value.status_code == 502
# --- /x12/validate -----------------------------------------------------
def test_validate_interchange_returns_operation_result():
"""A 200 response is returned verbatim — Status + Details."""
operation_result = {
"Status": "success",
"Details": [],
"LastIndex": 46,
}
x12 = {
"SegmentDelimiter": "~",
"DataElementDelimiter": "*",
"ISA": {"InterchangeControlNumber_13": "000000001"},
"Groups": [],
"IEATrailers": [],
}
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
assert body["ISA"]["InterchangeControlNumber_13"] == "000000001"
return httpx.Response(200, json=operation_result)
_install_factory(handler)
result = edifabric.validate_interchange(x12, api_key=_TEST_KEY)
assert result["Status"] == "success"
assert result["Details"] == []
def test_validate_interchange_does_not_raise_on_status_error():
"""OperationResult.Status='error' is data, not an exception — the
caller (the gate / CLI) decides whether to fail-closed."""
operation_result = {
"Status": "error",
"Details": [
{"Index": 5, "SegmentId": "PER", "Message": "PER-04 is required", "Status": "error"},
],
"LastIndex": 5,
}
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=operation_result)
_install_factory(handler)
result = edifabric.validate_interchange({"ISA": {}}, api_key=_TEST_KEY)
assert result["Status"] == "error"
assert result["Details"][0]["Message"] == "PER-04 is required"
# --- 4xx / 5xx ---------------------------------------------------------
def test_validate_interchange_raises_on_5xx():
"""Non-2xx responses raise EdifabricError; the body is preserved."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(503, text="upstream overloaded")
_install_factory(handler)
with pytest.raises(edifabric.EdifabricError) as exc_info:
edifabric.validate_interchange({"ISA": {}}, api_key=_TEST_KEY)
assert exc_info.value.status_code == 503
assert "upstream overloaded" in str(exc_info.value.body)
# --- validate_edi (composed) ------------------------------------------
def test_validate_edi_composes_read_then_validate():
"""validate_edi should call read first, then validate with the
X12Interchange JSON from read's response."""
x12 = {
"SegmentDelimiter": "~",
"DataElementDelimiter": "*",
"ISA": {"InterchangeControlNumber_13": "000000099"},
"Groups": [],
"IEATrailers": [],
}
operation_result = {"Status": "success", "Details": [], "LastIndex": 10}
seen_calls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/read"):
seen_calls.append("read")
return httpx.Response(200, json=[x12])
if request.url.path.endswith("/validate"):
seen_calls.append("validate")
# Verify the validate body is the X12Interchange JSON
body = json.loads(request.content)
assert body["ISA"]["InterchangeControlNumber_13"] == "000000099"
return httpx.Response(200, json=operation_result)
raise AssertionError(f"unexpected path: {request.url.path}")
_install_factory(handler)
result = edifabric.validate_edi(b"ISA*...~IEA*0*000000099~", api_key=_TEST_KEY)
assert seen_calls == ["read", "validate"]
assert result["Status"] == "success"
# --- API key handling --------------------------------------------------
def test_validate_edi_raises_when_api_key_missing(monkeypatch):
"""With no key configured anywhere, validate_edi surfaces a clear
error to the operator (not a generic 500)."""
monkeypatch.delenv("CYCLONE_EDIFABRIC_API_KEY", raising=False)
def handler(request: httpx.Request) -> httpx.Response:
raise AssertionError("transport should not be called when key is missing")
_install_factory(handler)
with pytest.raises(edifabric.EdifabricError) as exc_info:
edifabric.validate_edi(b"ISA*...~IEA*0*000000001~")
assert exc_info.value.status_code == 0
assert "API key not configured" in str(exc_info.value.body)
+248
View File
@@ -203,6 +203,7 @@ def test_resubmit_rejected_claims_inserts_resubmission_row(tmp_path, monkeypatch
"--ingest-dir", str(tmp_path),
"--limit", "1",
"--no-validate",
"--skip-edifabric-validate",
])
assert result.exit_code == 0, (
f"CLI exited {result.exit_code}, output={result.output!r}, "
@@ -279,7 +280,254 @@ def test_resubmit_idempotency_does_not_create_duplicate_rows(tmp_path, monkeypat
"--ingest-dir", str(tmp_path),
"--limit", "1",
"--no-validate",
"--skip-edifabric-validate",
])
# On the skip path, record_resubmission should NOT be called.
assert captured == [], f"expected no record_resubmission on skip, got {captured!r}"
assert "skipped" in result.output, result.output
# ---------------------------------------------------------------------------
# SP40: `cyclone resubmit-rejected-claims` Edifabric pre-upload gate.
# ---------------------------------------------------------------------------
_TEST_KEY = "test-edifabric-key-0123456789abcdef"
def _install_edifabric_mock(handler):
"""Install a mocked Edifabric transport + get_secret stub for the
duration of a single test. Returned teardown callable can be wired
to monkeypatch via the autouse fixture pattern below."""
import sys
import httpx
from cyclone import edifabric
import cyclone.secrets as _secrets_mod
real_get_secret = _secrets_mod.get_secret
def _fake_get_secret(name):
if name == "edifabric.api_key":
return _TEST_KEY
return real_get_secret(name)
_secrets_mod.get_secret = _fake_get_secret
transport = httpx.MockTransport(handler)
edifabric.set_transport_factory(
lambda: httpx.Client(transport=transport, timeout=10.0)
)
def _teardown():
_secrets_mod.get_secret = real_get_secret
edifabric._reset_transport_factory()
return _teardown
def _two_files_setup(tmp_path):
"""Stage two .x12 files in distinct batch dirs so the test can
observe one file's gate failure not blocking the other's upload."""
src_text = open("tests/fixtures/co_medicaid_837p.txt").read()
a = tmp_path / "batch-aaaaaaaa-1-claims"
a.mkdir()
(a / "tp1-aaaaaaaa-1of1.x12").write_text(src_text)
b = tmp_path / "batch-bbbbbbbb-1-claims"
b.mkdir()
(b / "tp1-bbbbbbbb-1of1.x12").write_text(src_text)
return src_text
def test_resubmit_edifabric_gate_aborts_bad_file_continues_batch(
tmp_path, monkeypatch,
):
"""SP40: when Edifabric returns Status='error' for one file, that
file is recorded as ``validate_failed`` (not uploaded, no
Resubmission row), but the batch continues with the next file."""
import sys
import types
from cyclone.cli import resubmit_rejected_claims
_seed_clearhouse_stub_false(tmp_path)
_two_files_setup(tmp_path)
seen: list[str] = []
x12 = {
"SegmentDelimiter": "~",
"DataElementDelimiter": "*",
"ISA": {"InterchangeControlNumber_13": "000000001"},
"Groups": [],
"IEATrailers": [],
}
def edifabric_handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/read"):
return httpx.Response(200, json=[x12])
if request.url.path.endswith("/validate"):
# Fail the FIRST validate call; succeed on the second.
seen.append("validate")
is_bad = len(seen) == 1
return httpx.Response(200, json={
"Status": "error" if is_bad else "success",
"Details": (
[
{"SegmentId": "PER",
"Message": "PER-04 is required",
"Status": "error"},
]
if is_bad else []
),
"LastIndex": 5 if is_bad else 46,
})
raise AssertionError(f"unexpected path: {request.url.path}")
import httpx
teardown = _install_edifabric_mock(edifabric_handler)
try:
# Fake paramiko so SFTP doesn't actually fire.
fake_paramiko = types.ModuleType("paramiko")
class _FakeAttr:
def __init__(self, size: int) -> None:
self.st_size = size
class _FakeSFTP:
def __init__(self):
self.put_calls: list[str] = []
def stat(self, path):
raise IOError("not found")
def put(self, local, remote):
self.put_calls.append(remote)
fake_sftp = _FakeSFTP()
class _FakeSSH:
def open_sftp(self):
return fake_sftp
def set_missing_host_key_policy(self, policy):
return None
def connect(self, *a, **kw):
return None
def close(self):
return None
fake_paramiko.SSHClient = lambda: _FakeSSH()
fake_paramiko.AutoAddPolicy = lambda: None
monkeypatch.setitem(sys.modules, "paramiko", fake_paramiko)
runner = CliRunner()
result = runner.invoke(resubmit_rejected_claims, [
"--ingest-dir", str(tmp_path),
"--no-validate",
# default: gate ENABLED
])
assert result.exit_code == 0, result.output
# First file: Edifabric gate aborted it (no SFTP put for it).
# Second file: passed the gate, got uploaded via the SFTP put.
assert len(fake_sftp.put_calls) == 1, fake_sftp.put_calls
assert "bbbbbbbb" in fake_sftp.put_calls[0], fake_sftp.put_calls[0]
# Output surfaces the failure list.
assert "EDIFABRIC VALIDATION FAIL" in result.output, result.output
assert "1 file(s) blocked by Edifabric gate" in result.output, result.output
assert "aaaaaaa" in result.output, result.output
assert "PER-04 is required" in result.output, result.output
# DONE summary shows both counters.
assert "validate_failed=1" in result.output, result.output
assert "uploaded=1" in result.output, result.output
finally:
teardown()
def test_resubmit_edifabric_skip_flag_bypasses_gate(tmp_path, monkeypatch):
"""SP40: ``--skip-edifabric-validate`` bypasses the gate entirely
so the same Edifabric-failing file uploads via SFTP. Used for
dev / hot-fix scenarios."""
import sys
import types
from cyclone.cli import resubmit_rejected_claims
_seed_clearhouse_stub_false(tmp_path)
_two_files_setup(tmp_path)
import httpx
x12 = {
"SegmentDelimiter": "~",
"DataElementDelimiter": "*",
"ISA": {"InterchangeControlNumber_13": "000000001"},
"Groups": [],
"IEATrailers": [],
}
validate_calls: list[str] = []
def edifabric_handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/read"):
return httpx.Response(200, json=[x12])
if request.url.path.endswith("/validate"):
validate_calls.append("hit")
return httpx.Response(200, json={
"Status": "error",
"Details": [{"SegmentId": "PER",
"Message": "PER-04 is required",
"Status": "error"}],
"LastIndex": 5,
})
raise AssertionError(f"unexpected path: {request.url.path}")
teardown = _install_edifabric_mock(edifabric_handler)
try:
fake_paramiko = types.ModuleType("paramiko")
class _FakeSFTP:
def __init__(self):
self.put_calls: list[str] = []
def stat(self, path):
raise IOError("not found")
def put(self, local, remote):
self.put_calls.append(remote)
fake_sftp = _FakeSFTP()
class _FakeSSH:
def open_sftp(self):
return fake_sftp
def set_missing_host_key_policy(self, policy):
return None
def connect(self, *a, **kw):
return None
def close(self):
return None
fake_paramiko.SSHClient = lambda: _FakeSSH()
fake_paramiko.AutoAddPolicy = lambda: None
monkeypatch.setitem(sys.modules, "paramiko", fake_paramiko)
runner = CliRunner()
result = runner.invoke(resubmit_rejected_claims, [
"--ingest-dir", str(tmp_path),
"--no-validate",
"--skip-edifabric-validate",
])
assert result.exit_code == 0, result.output
# Both files uploaded; the gate never fired.
assert len(fake_sftp.put_calls) == 2, fake_sftp.put_calls
assert validate_calls == [], (
"validate must not be called when --skip-edifabric-validate is set"
)
assert "validate_failed=0" in result.output, result.output
assert "EDIFABRIC VALIDATION FAIL" not in result.output, result.output
finally:
teardown()
+49
View File
@@ -0,0 +1,49 @@
"""SP40: tests for the edifabric.api_key secret wiring.
Specifically: the ``_ENV_NAME_FOR`` table maps ``edifabric.api_key``
to ``CYCLONE_EDIFABRIC_API_KEY`` so operators can configure via env
var (or via file companion per ``secrets.get_secret``'s
``<env_name>_FILE`` form) without touching the macOS Keychain.
"""
from __future__ import annotations
from pathlib import Path
def test_env_name_for_maps_edifabric_api_key():
from cyclone.secrets import _ENV_NAME_FOR
assert _ENV_NAME_FOR["edifabric.api_key"] == "CYCLONE_EDIFABRIC_API_KEY"
def test_get_secret_reads_edifabric_key_from_env(monkeypatch):
"""With the env var set, ``get_secret('edifabric.api_key')``
returns it without touching the Keychain."""
monkeypatch.setenv("CYCLONE_EDIFABRIC_API_KEY", "test-env-key-9876")
from cyclone import secrets
assert secrets.get_secret("edifabric.api_key") == "test-env-key-9876"
def test_get_secret_reads_edifabric_key_from_file(monkeypatch, tmp_path):
"""With ``<env_name>_FILE`` set, ``get_secret`` reads the key from
the file (no Keychain touch). Mirrors the existing SFTP password
file-companion pattern (SP25)."""
key_file = tmp_path / "edi_key"
key_file.write_text("file-companion-key-5555\n")
monkeypatch.setenv("CYCLONE_EDIFABRIC_API_KEY_FILE", str(key_file))
from cyclone import secrets
assert secrets.get_secret("edifabric.api_key") == "file-companion-key-5555"
def test_dev_fixture_file_exists_and_contains_eval_key():
"""The dev-only fixture exists at the documented path so the
eval key is referenceable; the CI mocks intercept before any
real HTTP happens."""
fixture = Path("tests/fixtures/edifabric_api_key.txt")
assert fixture.exists(), (
f"dev-only Edifabric API key fixture is missing at {fixture}"
)
contents = fixture.read_text().strip()
# Public eval key from the EdiNation API docs.
assert "3ecf6b1c5cf34bd797a5f4c57951a1cf" in contents
+56 -1
View File
@@ -682,4 +682,59 @@ def test_2010bb_preserves_foreign_payer_id_verbatim():
assert parts[9] == "OTHER_PAYER"
assert parts[3] == "OTHER PAYER NAME"
# No substitution log for a foreign payer id
assert "SP39" not in log_stream.getvalue()
assert "SP39" not in log_stream.getvalue()
# ---------------------------------------------------------------------------
# SP40: PER-02/03/04 + SBR-09 must always be emitted (Edifabric rejects
# the bare-PER / no-SBR09 shapes). The serializer fall-back fills them
# with safe placeholders when the caller passes no kwargs.
# ---------------------------------------------------------------------------
def test_per_segment_emits_name_and_phone_qualifier_when_no_contact():
"""SP40: without any submitter_contact_* kwargs, the submitter
block must still produce a PER segment with at least PER-02 (Name)
and a PER-03 (Communication Number Qualifier) + PER-04 (Number)
pair. Edifabric rejects PER*IC alone."""
claim = _load_claim()
text = serialize_837_for_resubmit(claim, interchange_index=42)
per_line = next(s for s in text.split("~") if s.startswith("PER"))
parts = per_line.split("*")
# PER*IC*<Name>*<Qual>*<Number>
assert parts[1] == "IC"
assert parts[2], f"PER-02 (Name) is required, got empty; line={per_line!r}"
assert parts[3] in {"TE", "EM", "FX"}, (
f"PER-03 must be a Communication Number Qualifier (TE/EM/FX); "
f"got {parts[3]!r}; line={per_line!r}"
)
assert parts[4], f"PER-04 (Number) is required, got empty; line={per_line!r}"
def test_sbr_segment_emits_claim_filing_indicator_default_mc():
"""SP40: SBR-09 (Claim Filing Indicator Code) must always be
emitted. Default is 'MC' (Medicaid) when the caller passes no
claim_filing_indicator_code kwarg. Edifabric rejects the bare
SBR*P*18******* shape."""
claim = _load_claim()
text = serialize_837_for_resubmit(claim, interchange_index=42)
sbr_line = next(s for s in text.split("~") if s.startswith("SBR"))
parts = sbr_line.split("*")
# SBR*P*18*******MC → index 9 is SBR-09
assert len(parts) >= 10, f"SBR must have 10 elements (with SBR-09), got {len(parts)}: {sbr_line!r}"
assert parts[9] == "MC", (
f"SBR-09 must default to 'MC' (Medicaid), got {parts[9]!r}; line={sbr_line!r}"
)
def test_sbr_segment_respects_explicit_claim_filing_indicator():
"""SP40: explicit claim_filing_indicator_code kwargs win over the
default — preserves the existing caller-facing behavior for
non-CO payers (e.g. '16' for Medicare Part B)."""
claim = _load_claim()
text = serialize_837_for_resubmit(
claim, interchange_index=42,
claim_filing_indicator_code="16",
)
sbr_line = next(s for s in text.split("~") if s.startswith("SBR"))
parts = sbr_line.split("*")
assert parts[9] == "16", f"SBR-09 should reflect explicit kwarg, got {parts[9]!r}"
+125
View File
@@ -0,0 +1,125 @@
"""SP40: tests for `cyclone validate-837 <file>` CLI."""
from __future__ import annotations
import json
from pathlib import Path
import httpx
import pytest
from click.testing import CliRunner
from cyclone import edifabric
from cyclone.cli import validate_837_cmd
_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(autouse=True)
def _reset_transport():
yield
edifabric._reset_transport_factory()
# --- happy path -------------------------------------------------------
def test_validate_837_success_prints_status_and_exits_zero(tmp_path):
"""A clean file with Status='success' from Edifabric exits 0 and
prints the OperationResult summary."""
fixture = Path("/home/tyler/dev/cyclone/backend/tests/fixtures/co_medicaid_837p.txt")
sample = tmp_path / "ok.x12"
sample.write_text(fixture.read_text())
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/read"):
return httpx.Response(200, json=[{
"SegmentDelimiter": "~",
"DataElementDelimiter": "*",
"ISA": {"InterchangeControlNumber_13": "000000001"},
"Groups": [],
"IEATrailers": [],
}])
if request.url.path.endswith("/validate"):
return httpx.Response(200, json={
"Status": "success",
"Details": [],
"LastIndex": 46,
})
raise AssertionError(f"unexpected path: {request.url.path}")
_install(handler)
runner = CliRunner()
result = runner.invoke(validate_837_cmd, [str(sample), "--api-key", _TEST_KEY])
assert result.exit_code == 0, result.output
assert "Status: success" in result.output
assert "Details: 0 item(s)" in result.output
def test_validate_837_error_status_exits_two_and_prints_details(tmp_path):
"""Status='error' from Edifabric exits 2 and prints the offending
segment + message for each Details item."""
sample = tmp_path / "bad.x12"
sample.write_text("ISA*bad~")
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/read"):
return httpx.Response(200, json=[{
"SegmentDelimiter": "~",
"DataElementDelimiter": "*",
"ISA": {"InterchangeControlNumber_13": "000000001"},
"Groups": [],
"IEATrailers": [],
}])
if request.url.path.endswith("/validate"):
return httpx.Response(200, json={
"Status": "error",
"Details": [
{"SegmentId": "PER", "Message": "PER-04 is required", "Status": "error"},
{"SegmentId": "SBR", "Message": "SBR-09 is required", "Status": "error"},
],
"LastIndex": 5,
})
raise AssertionError(f"unexpected path: {request.url.path}")
_install(handler)
runner = CliRunner()
result = runner.invoke(validate_837_cmd, [str(sample), "--api-key", _TEST_KEY])
assert result.exit_code == 2, result.output
assert "Status: error" in result.output
assert "PER: PER-04 is required" in result.output
assert "SBR: SBR-09 is required" in result.output
def test_validate_837_missing_api_key_exits_one(tmp_path, monkeypatch):
"""With no API key anywhere (env var unset, keychain empty), the
CLI exits 1 and surfaces a clear 'API key not configured' error."""
monkeypatch.delenv("CYCLONE_EDIFABRIC_API_KEY", raising=False)
sample = tmp_path / "any.x12"
sample.write_text("ISA*...~")
def handler(request: httpx.Request) -> httpx.Response:
raise AssertionError("transport should not be called when key is missing")
_install(handler)
runner = CliRunner()
result = runner.invoke(validate_837_cmd, [str(sample)])
assert result.exit_code == 1, result.output
assert "API key not configured" in result.output
def test_validate_837_file_not_found_exits_two():
"""Click's path validation rejects a missing file before the
command body runs (exits 2 via Click's UsageError)."""
runner = CliRunner()
result = runner.invoke(validate_837_cmd, ["/nonexistent/path.x12", "--api-key", _TEST_KEY])
assert result.exit_code == 2, result.output
+119
View File
@@ -323,6 +323,125 @@ curl -X PATCH http://127.0.0.1:8000/api/clearhouse \
To revert, set `stub: true` and `auth: {"method": "keychain", "secret_ref": "sftp.gainwell.password"}`.
### After SP40 lands — Edifabric /v2/x12/validate integration
SP40 wires Edifabric's reference X12 validator into the push flow
as a fail-closed pre-upload gate, plus a CLI / HTTP endpoint for
ad-hoc validation of single files. The integration catches the
same byte-level defects (`PER-04 required`, `SBR-09 required`,
etc.) Edifabric flagged on the SP39 regen `ingest/corrected-v2/`.
**One-time secret setup** (operator-only, never commit):
```bash
# Option A — macOS Keychain (preferred, paid tier key):
cyclone secrets set edifabric.api_key <paid-tier-key>
# → stores in Keychain under service='cyclone', account='edifabric.api_key'
# Option B — env var (CI / headless / per-process):
export CYCLONE_EDIFABRIC_API_KEY="<paid-tier-key>"
# Option C — file companion (matches the SP25 SFTP-password pattern):
export CYCLONE_EDIFABRIC_API_KEY_FILE=/path/to/edi-key.txt
# file should be chmod 600, single-line, no trailing newline needed
```
The dev-only `tests/fixtures/edifabric_api_key.txt` holds the
free `EdiNation Developer API` provisional key from the public docs
(`3ecf6b1c5cf34bd797a5f4c57951a1cf`); CI uses the mocked transport
so the key is never actually transmitted.
**Single-file validation — CLI**
```bash
# Status printout + OperationResult summary; exits 0 / 1 / 2.
.venv/bin/python -m cyclone.cli validate-837 ingest/corrected-v2/v2-batch-<id>-<N>-claims/tp-<id>-1of1.x12
```
Exit codes (matches the rest of the validator family):
- `0` — Edifabric returned `Status="success"` (warnings are non-blocking).
- `2` — `Status="error"`: at least one `Details` item has
`Status="error"`. The CLI prints the first 5 offending segment
IDs + messages, then exits.
- `1` — Edifabric client config error (no API key, or transport
exception). The operator must fix `cyc secrets set` before retrying.
**Single-file validation — HTTP (auth-gated)**
```bash
curl -b admin-cookies.txt -X POST \
-F "file=@ingest/corrected-v2/v2-batch-.../tp-...-1of1.x12" \
http://127.0.0.1:8000/api/admin/validate-837
# → 200 with the raw OperationResult JSON (Status + Details + LastIndex)
# Caller inspects Status to decide whether the file is acceptable.
```
`POST /api/admin/validate-837` is matrix-gated (ADMIN_ONLY); 401
without a session cookie, 403 with a viewer cookie.
**Pre-upload gate on resubmit-rejected-claims**
`cyclone resubmit-rejected-claims` runs Edifabric validation
before every SFTP `put`. Any file that returns `Status="error"`
is skipped (not uploaded, no `Resubmission` row inserted), and the
batch continues with the remaining files. After the run:
```bash
.venv/bin/python -m cyclone.cli resubmit-rejected-claims \
--ingest-dir /home/tyler/dev/cyclone/ingest/corrected-v2
# ...
# EDIFABRIC VALIDATION FAIL tp-cyc-deadbeef-1of1.x12: 2 error(s)
# [error] PER: PER-04 is required
# [error] SBR: SBR-09 is required
# ...
# DONE uploaded=361 skipped=0 failed=0 validated=363 validate_failed=2 ...
#
# 2 file(s) blocked by Edifabric gate:
# tp-cyc-aaaaaaaa-1of1.x12
# [error] PER: PER-04 is required
# tp-cyc-bbbbbbbb-1of1.x12
# [error] SBR: SBR-09 is required
```
To bypass the gate (dev only — for hot-fix scenarios where
Edifabric is the bottleneck):
```bash
.venv/bin/python -m cyclone.cli resubmit-rejected-claims \
--ingest-dir ... \
--skip-edifabric-validate
```
**Expected OperationResult shape** (verbatim from EdiNation docs):
```json
{
"Status": "success" | "warning" | "error",
"Details": [
{"Index": 5, "SegmentId": "PER", "Value": "...",
"Message": "PER-04 is required", "Status": "error"}
],
"LastIndex": 46
}
```
Warnings (`Status="warning"`) never block. Only `Details` items
with `Status="error"` fail the gate.
**Troubleshooting**
- `API key not configured` → run
`cyclone secrets set edifabric.api_key <key>` (or set
`CYCLONE_EDIFABRIC_API_KEY_FILE`).
- `502 Edifabric upstream failure` → check
https://status.edifabric.com/ (or the API status page); the
gate treats any 4xx/5xx from Edifabric as a hard fail for that
file but continues the batch.
- After fixing the byte defect, re-run
`cyclone resubmit-rejected-claims` for the blocked file —
idempotent on `(claim_id, interchange_control_number)`.
## Known historical drift — the 804 orphan 999s
The `acks` table may hold several hundred 999 acks whose source 837
@@ -0,0 +1,124 @@
# SP40 — Edifabric validation gate + 837P byte-defect sweep
**Date:** 2026-07-07
**Status:** Draft, awaiting user sign-off
**Branch:** `sp40-edifabric-validation-gate`
**Aesthetic direction:** No new UI; one new CLI command + one HTTP endpoint + serializer hardening + a defensive HTTP client. Three new tests, two modified files, two new docs.
---
## 1. Scope
Edifabric's `POST /v2/x12/validate` (the operator's reference parser for CO Medicaid compliance) rejected the SP39-regenerated 837P files with three byte-level errors:
```
Line 1: Element PER-03 is required
Line 1: Element PER-04 is required
Line 1: Element SBR-09 is required
```
Root cause: `serialize_837._build_per` emits `PER*IC` (only PER01), and `_build_sbr` emits `SBR*P*18*******` (no SBR09) when the caller passes no contact / SBR09 kwargs. The clearhouse config has `submitter_contact_name="Tyler Martinez"` and `submitter_contact_email="tyler@dzinesco.com"` seeded, and `PayerConfig.co_medicaid().sbr09_claim_filing = "MC"` — both are available to the serializer today but not wired into the default emit. SP33-era callers and the SP39 regen script both bypass the kwargs, so every file they emit is invalid.
SP40 lands (a) the serializer fixes that close the gap, (b) the Edifabric integration that catches the same class of defect for any future payer / schema drift, (c) a pre-upload gate on `resubmit-rejected-claims` so the operator can't push an Edifabric-invalid file to Gainwell, and (d) the API key wiring through the existing `secrets.get_secret()` plumbing.
**In scope:**
- `_build_per` always emits PER02 (Name) + at least one PER03/PER04 pair (email or phone), sourced from `claim.billing_provider.contact` or the clearhouse config when the caller passes nothing.
- `_build_sbr` always emits SBR09 from `payer_cfg.sbr09_claim_filing` (already `"MC"` for CO Medicaid).
- A new `cyclone.edifabric` module — thin `httpx` client wrapping the two-step Edifabric flow (`/x12/read``/x12/validate`).
- A new CLI `cyclone validate-837 <file>` — reads the file, hits Edifabric, prints `OperationResult`; exits 0 on success / warning-only, 2 on any error.
- A new HTTP `POST /api/admin/validate-837` (admin-gated) — multipart upload of the .x12 file; same response shape.
- A pre-upload gate inside `resubmit-rejected-claims` — every file is validated before the SFTP `put`; `Status == "error"` aborts the file but continues the batch (other files still push). Bypass flag `--skip-edifabric-validate` for dev.
- API key wiring: `secrets.get_secret('edifabric.api_key')` with a new `_ENV_NAME_FOR` entry mapping it to `CYCLONE_EDIFABRIC_API_KEY`.
- A dev-fixture API key in `tests/fixtures/edifabric_api_key.txt` so CI doesn't need a real subscription (the public eval key `3ecf6b1c5cf34bd797a5f4c57951a1cf`).
- A `PERMISSIONS` matrix entry for `("POST", "/api/admin/validate-837"): ADMIN_ONLY`.
- RUNBOOK updates for the env var, the new CLI/HTTP, and the bypass flag.
**Out of scope:**
- 999 / 277CA / TA1 / 835 / 270 / 271 Edifabric validation (only 837P this SP).
- Building a custom EDI → X12Interchange JSON converter (we use Edifabric's `/x12/read` to do the conversion — see D1).
- Auto-retry on transient Edifabric 5xx — the gate surfaces the error; the operator decides whether to retry.
- Refreshing the 363 regen'd `ingest/corrected-v2/` files — the regen script already runs cleanly through the fixed serializer once Task 1 lands.
## 2. Decisions
- **D1.** Two-step Edifabric flow: `/x12/read` (raw EDI → `X12Interchange` JSON, `Content-Type: application/octet-stream`) then `/x12/validate` (JSON → `OperationResult`, `Content-Type: application/json`). Building our own EDI → JSON converter would be ~150 fields; `/x12/read` is the pragmatic path and the same library parses files we generate.
- **D2.** API key via `secrets.get_secret('edifabric.api_key')``CYCLONE_EDIFABRIC_API_KEY` env var or Keychain (`cyclone` service, `edifabric.api_key` account). The eval key in `tests/fixtures/edifabric_api_key.txt` is dev-only; production is operator-supplied via `cyclone secrets set edifabric.api_key <paid-key>`.
- **D3.** Pre-upload gate is **fail-closed**: any `Status == "error"` item in `OperationResult.Details` aborts the file (file is recorded as `failed`, batch continues). Warnings are logged at INFO level but don't block. This matches the operator's stated goal ("claims arent passing verification").
- **D4.** Serializer hardening is minimal + backward-compatible:
- `_build_per(contact_name, contact_phone, contact_email)` already handles all three; the fix is at the call site (where `submitter_kwargs` is built) — fall back to `clearhouse.submitter_contact_*` when the caller passes no kwargs. When none of those are set either, emit `PER*IC*CUSTOMER SERVICE*TE*8005550100` (a safe placeholder; documented in the function docstring as a temporary fallback so future per-billing-office contact info lands at the call site instead).
- `_build_sbr(individual_relationship_code, claim_filing_indicator_code)` already takes the SBR09 value; the fix is at the call site — pass `payer_cfg.sbr09_claim_filing` (default `"MC"` for CO Medicaid). When `payer_cfg` is unavailable, fall back to `"MC"` (matches the SP9-era seeding default).
- **D5.** HTTP client uses `httpx` (already in `pyproject.toml:35` `httpx>=0.27,<1`). One new module `cyclone.edifabric` with three functions:
- `read_interchange(edi_bytes, *, api_key) -> X12Interchange`
- `validate_interchange(x12_json, *, api_key) -> OperationResult`
- `validate_edi(edi_bytes, *, api_key) -> OperationResult` — composes the two.
Custom 4xx/5xx handling raises `EdifabricError(status, body)` so the caller can surface the Edifabric error verbatim.
- **D6.** HTTP endpoint is `POST /api/admin/validate-837` (matches existing `/api/admin/*` admin-only pattern from `cyclone/api_routers/admin.py:38-39`). Body is `multipart/form-data` with a single `file` part (FastAPI's `UploadFile`); response is the raw `OperationResult` JSON. The CLI uses the same module functions directly so the wire shape stays consistent.
- **D7.** Test mock strategy: `httpx.MockTransport` registered against the `httpx.Client` instance inside the `cyclone.edifabric` module (factory pattern lets tests inject the transport). No live HTTP in CI; the eval API key in `tests/fixtures/` is referenced but never sent — the mock intercepts first.
- **D8.** Pre-upload gate bypass: `--skip-edifabric-validate` flag on the CLI; no env var. Bypassing is an explicit operator action, not a global setting, so it's hard to forget about.
## 3. Files expected to change
| Path | Type | Purpose |
|---|---|---|
| `backend/src/cyclone/parsers/serialize_837.py` | modify | `_build_per` call site: fall back to clearhouse contact info. `_build_sbr` call site: always pass `sbr09_claim_filing`. |
| `backend/src/cyclone/edifabric.py` | create | `read_interchange`, `validate_interchange`, `validate_edi`, `EdifabricError`. |
| `backend/src/cyclone/cli.py` | modify | New `validate_837_cmd` (read file → call `validate_edi` → print `OperationResult`). Pre-upload gate call inside `resubmit_rejected_claims`. New `--skip-edifabric-validate` flag. |
| `backend/src/cyclone/api_routers/admin.py` | modify | New `POST /api/admin/validate-837` (multipart upload, returns `OperationResult`). |
| `backend/src/cyclone/secrets.py` | modify | Add `"edifabric.api_key": "CYCLONE_EDIFABRIC_API_KEY"` to `_ENV_NAME_FOR`. |
| `backend/src/cyclone/auth/permissions.py` | modify | Add `("POST", "/api/admin/validate-837"): ADMIN_ONLY`. |
| `backend/tests/test_serialize_837.py` | extend | 3 new regression tests: PER-02/03/04 always present, SBR-09 always present, no-kwargs fallback still emits valid PER + SBR. |
| `backend/tests/test_edifabric.py` | create | 4 tests with `httpx.MockTransport`: read succeeds, validate succeeds, validate returns errors, 5xx surfaces EdifabricError. |
| `backend/tests/test_validate_837_cli.py` | create | 4 CliRunner tests: clean file → exit 0 + status printed, error → exit 2, missing API key → exit 1, file not found → exit 2. |
| `backend/tests/test_api_validate_837.py` | create | 3 TestClient tests: admin auth gate (401 / 403), valid upload returns OperationResult, missing file part returns 422. |
| `backend/tests/test_resubmissions_cli.py` | extend | 1 new test: validation fail mid-batch aborts that file but continues the others; `--skip-edifabric-validate` flag bypasses the gate. |
| `backend/tests/fixtures/edifabric_api_key.txt` | create | The public eval key `3ecf6b1c5cf34bd797a5f4c57951a1cf` (file-based secret, read via `_FILE` env var in tests). |
| `docs/RUNBOOK.md` | modify | New "After SP40 lands" section: env var, secret setup, CLI/HTTP usage, bypass flag, expected OperationResult shape. |
| `docs/superpowers/specs/2026-07-07-cyclone-edifabric-validation-gate-design.md` | create | This spec, committed on the branch. |
## 4. Auth boundary
The HTTP endpoint inherits the existing admin router's `Depends(matrix_gate)` gate (`cyclone/api_routers/admin.py:39`); `PERMISSIONS` entry is `ADMIN_ONLY`. The API key never leaves the backend — the CLI and the HTTP handler both call `cyclone.edifabric.validate_edi(...)` server-side. The CLI is operator-only (no auth boundary in the CLI itself, per house convention). File-system threats unchanged: SQLCipher at rest, macOS Keychain for the secret. No new ports.
## 5. Open questions
- Should the `validate-837` HTTP endpoint accept a JSON body `{content: "ISA*..."}` as an alternative to multipart upload? Pro: easier UI integration. Con: more attack surface (request body in DB-sized chunks). Defer to a future SP if needed.
- Should the gate emit a per-claim `Resubmission` row update when validation fails? The current `Resubmission` row is only written on successful SFTP push, so a fail-closed gate would skip the row entirely — clean. If the operator wants an audit trail of *attempted* pushes, that's a follow-up column on `Resubmission`. Defer.
## 6. Test impact
- `test_serialize_837.py`: 3 new regression tests. Total file: 53 + 3 = 56 tests.
- `test_edifabric.py`: 4 new tests with mocked transport.
- `test_validate_837_cli.py`: 4 new CliRunner tests.
- `test_api_validate_837.py`: 3 new TestClient tests (auth gate, valid upload, missing file).
- `test_resubmissions_cli.py`: 1 new gate-fail test + 1 bypass-flag test.
- Total new tests: 13. Full suite target: 1458 + 13 = 1471 passing.
## 7. Acceptance criteria
- All 3 new serializer tests pass.
- `cyclone validate-837 backend/tests/fixtures/co_medicaid_837p.txt` exits 0 and prints `Status: success` (mocked, no live HTTP).
- `cyclone validate-837 ingest/corrected-v2/v2-batch-*/tp-cyc-*.x12` (363 files) — all exit 0 (no PER/SBR errors) once the regen is re-run with the fixed serializer. Spot-checked with `grep NM1*PR2*CO_TXIX` per file.
- `POST /api/admin/validate-837` returns 401 unauthenticated, 403 with viewer cookie, 200 with admin cookie + multipart file.
- `resubmit-rejected-claims --ingest-dir ingest/corrected-v2` aborts files whose `OperationResult.Status == "error"`, continues with others, prints the failure list at the end.
- `--skip-edifabric-validate` flag bypasses the gate (for dev only).
- `secrets._ENV_NAME_FOR['edifabric.api_key'] == 'CYCLONE_EDIFABRIC_API_KEY'`.
- `cyclone secrets set edifabric.api_key <key>` persists; `get_secret('edifabric.api_key')` reads from Keychain on subsequent boots.
- RUNBOOK.md has the "After SP40 lands" section.
## 8. Implementation plan summary
(Full plan with checkboxes goes in `docs/superpowers/plans/2026-07-07-cyclone-edifabric-validation-gate.md` once this spec lands.)
| Task | Scope | Estimated commits |
|---|---|---|
| 0 | Spec commit | `docs(spec): SP40 ...` |
| 1 | Serializer fixes (D4): `_build_per` + `_build_sbr` call sites, regression tests | `feat(sp40): 837P serializer always emits PER-02/03/04 + SBR-09` |
| 2 | `cyclone.edifabric` module + tests (D5, D7) | `feat(sp40): Edifabric HTTP client (read + validate)` |
| 3 | `cyclone validate-837` CLI + tests (D8) | `feat(sp40): cyclone validate-837 CLI` |
| 4 | `POST /api/admin/validate-837` HTTP + tests (D6) | `feat(sp40): POST /api/admin/validate-837 admin endpoint` |
| 5 | Pre-upload gate in `resubmit-rejected-claims` + bypass flag (D3, D8) | `feat(sp40): resubmit-rejected-claims pre-upload validation gate` |
| 6 | API key wiring: `_ENV_NAME_FOR` entry + dev fixture (D2) | `feat(sp40): edifabric.api_key secret wiring + dev fixture` |
| 7 | RUNBOOK.md "After SP40 lands" section (D5) | `docs(sp40): RUNBOOK entry for Edifabric validation` |
| 8 | Full suite + regen re-run + atomic merge | `merge: SP40 Edifabric validation gate + 837P byte-defect sweep into main` |