fix(api): use request.app.state in inbox endpoints; preserve dict HTTPException detail

- inbox_lanes, inbox_dismiss_candidates, inbox_export_csv: switch
  module-level 'app' to per-request 'request.app' so per-endpoint
  state stays consistent with the test's TestClient target across
  importlib.reload() (test_api.py::test_cors_extra_origins_via_env
  reloads the api module to mutate CORS allow-lists; pre-reload
  endpoints then mutate the wrong app instance).

- _http_exc_handler: when HTTPException.detail is a dict, wrap under
  'detail' so the standard envelope stays stable and callers can
  branch on body['detail']['error'].

- conftest._auto_init_db: re-resolve cyclone.api.app each fixture
  invocation (instead of caching at module load) so the reload pattern
  doesn't leave event_bus set on a stale app.

- test_acks.test_migration_latest_idempotent_on_fresh_db: bump
  user_version assertion from 12 to 14 after auth migration renumber
  (0013 users+sessions, 0014 audit_log.user_id).

Also installs sqlcipher3 + paramiko into the backend venv so the
capability tests can run; both modules were optional deps that
test_db_crypto and test_sftp_paramiko assume are present.

Backend test results: 1008 passed, 9 skipped (gitignored prodfile
fixtures), 0 failed.
This commit is contained in:
Nora
2026-06-22 16:55:55 -06:00
parent 81bcb1c1ef
commit e2d4a595a4
3 changed files with 58 additions and 18 deletions
+44 -11
View File
@@ -291,6 +291,16 @@ app.include_router(admin.router, dependencies=[Depends(matrix_gate)])
@app.exception_handler(HTTPException)
async def _http_exc_handler(request, exc: HTTPException):
if isinstance(exc.detail, dict):
# Endpoints that raise HTTPException with a dict detail (e.g.
# ``{"error": "Not found", "detail": "..."}``) want the dict
# preserved verbatim so the caller can branch on the `error`
# code. Wrap under `detail` for the standard envelope.
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
headers=exc.headers,
)
code = exc.detail if isinstance(exc.detail, str) else "error"
return JSONResponse(
status_code=exc.status_code,
@@ -1107,9 +1117,18 @@ def _serialize_ta1_from_row(row: db.Ta1Ack) -> str:
@app.get("/api/inbox/lanes", dependencies=[Depends(matrix_gate)])
def inbox_lanes():
"""Return all Inbox lanes in one call."""
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
def inbox_lanes(request: Request):
"""Return all Inbox lanes in one call.
Uses ``request.app.state`` rather than the module-level ``app``
global so the endpoint is robust against ``importlib.reload`` of
this module (some tests do this to mutate the CORS allow-list).
After a reload, the module-level ``app`` rebinds to a new
FastAPI instance; ``request.app`` always points at the instance
that is actually serving the current request, so per-request
state stays consistent with the test's TestClient target.
"""
dismissed_pairs = getattr(request.app.state, "dismissed_pairs", set())
with db.SessionLocal()() as session:
from cyclone.inbox_lanes import compute_lanes
lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
@@ -1154,16 +1173,25 @@ def inbox_match_candidate(remit_id: str, body: dict):
@app.post("/api/inbox/candidates/dismiss", dependencies=[Depends(matrix_gate)])
def inbox_dismiss_candidates(body: dict):
"""Add candidate pairs to the session-scoped dismissed set."""
def inbox_dismiss_candidates(body: dict, request: Request):
"""Add candidate pairs to the session-scoped dismissed set.
Uses ``request.app.state`` rather than the module-level ``app``
global so the endpoint is robust against ``importlib.reload`` of
this module (some tests do this to mutate the CORS allow-list).
After a reload, the module-level ``app`` rebinds to a new
FastAPI instance; ``request.app`` always points at the instance
that is actually serving the current request, so the test's
TestClient target is the one whose state we mutate.
"""
pairs = body.get("pairs") or []
if not hasattr(app.state, "dismissed_pairs"):
app.state.dismissed_pairs = set()
if not hasattr(request.app.state, "dismissed_pairs"):
request.app.state.dismissed_pairs = set()
for p in pairs:
cid = p.get("claim_id")
rid = p.get("remit_id")
if cid and rid:
app.state.dismissed_pairs.add(frozenset({cid, rid}))
request.app.state.dismissed_pairs.add(frozenset({cid, rid}))
return {"ok": True, "dismissed_count": len(pairs)}
@@ -1543,11 +1571,16 @@ def export_batch_837(request: Request, batch_id: str, body: dict):
@app.get("/api/inbox/export.csv", dependencies=[Depends(matrix_gate)])
def inbox_export_csv(lane: str):
"""Stream a CSV for a single lane."""
def inbox_export_csv(lane: str, request: Request):
"""Stream a CSV for a single lane.
Uses ``request.app.state`` rather than the module-level ``app``
global so the endpoint is robust against ``importlib.reload`` of
this module (see ``inbox_dismiss_candidates`` for context).
"""
if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
raise HTTPException(400, f"unknown lane: {lane}")
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
dismissed_pairs = getattr(request.app.state, "dismissed_pairs", set())
with db.SessionLocal()() as session:
from cyclone.inbox_lanes import compute_lanes
lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)