feat(auth): gate existing endpoints with matrix_gate dependency
This commit is contained in:
+47
-46
@@ -26,12 +26,13 @@ from datetime import datetime, timezone
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
|
||||
from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from cyclone import __version__, db
|
||||
from cyclone.auth.deps import matrix_gate
|
||||
from cyclone.db import Claim, ClaimState, Remittance
|
||||
from cyclone.inbox_state import apply_999_rejections
|
||||
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
||||
@@ -255,7 +256,7 @@ def health() -> dict[str, str]:
|
||||
return {"status": "ok", "version": __version__}
|
||||
|
||||
|
||||
@app.post("/api/parse-837")
|
||||
@app.post("/api/parse-837", dependencies=[Depends(matrix_gate)])
|
||||
async def parse_837(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
@@ -448,7 +449,7 @@ def _reconciliation_summary_for_batch(batch_id: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/parse-835")
|
||||
@app.post("/api/parse-835", dependencies=[Depends(matrix_gate)])
|
||||
async def parse_835_endpoint(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
@@ -592,7 +593,7 @@ def _ack_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
||||
return f"999-{(interchange_control_number or '').strip() or '000000001'}"
|
||||
|
||||
|
||||
@app.post("/api/parse-999")
|
||||
@app.post("/api/parse-999", dependencies=[Depends(matrix_gate)])
|
||||
async def parse_999_endpoint(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
@@ -718,7 +719,7 @@ def _ta1_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
||||
return f"TA1-{(interchange_control_number or '').strip() or '000000001'}"
|
||||
|
||||
|
||||
@app.post("/api/parse-ta1")
|
||||
@app.post("/api/parse-ta1", dependencies=[Depends(matrix_gate)])
|
||||
async def parse_ta1_endpoint(
|
||||
file: UploadFile = File(...),
|
||||
) -> Any:
|
||||
@@ -797,7 +798,7 @@ async def parse_ta1_endpoint(
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/ta1-acks")
|
||||
@app.get("/api/ta1-acks", dependencies=[Depends(matrix_gate)])
|
||||
def list_ta1_acks_endpoint(
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
) -> Any:
|
||||
@@ -815,7 +816,7 @@ def list_ta1_acks_endpoint(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/ta1-acks/{ack_id}")
|
||||
@app.get("/api/ta1-acks/{ack_id}", dependencies=[Depends(matrix_gate)])
|
||||
def get_ta1_ack_endpoint(ack_id: int) -> dict:
|
||||
"""Return one persisted TA1 ACK row with its parsed detail."""
|
||||
row = store.get_ta1_ack(ack_id)
|
||||
@@ -861,7 +862,7 @@ def _277ca_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
||||
return f"277CA-{(interchange_control_number or '').strip() or '000000001'}"
|
||||
|
||||
|
||||
@app.post("/api/parse-277ca")
|
||||
@app.post("/api/parse-277ca", dependencies=[Depends(matrix_gate)])
|
||||
async def parse_277ca_endpoint(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
@@ -980,7 +981,7 @@ async def parse_277ca_endpoint(
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/277ca-acks")
|
||||
@app.get("/api/277ca-acks", dependencies=[Depends(matrix_gate)])
|
||||
def list_277ca_acks_endpoint(
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
) -> Any:
|
||||
@@ -990,7 +991,7 @@ def list_277ca_acks_endpoint(
|
||||
return {"total": len(rows), "items": items}
|
||||
|
||||
|
||||
@app.get("/api/277ca-acks/{ack_id}")
|
||||
@app.get("/api/277ca-acks/{ack_id}", dependencies=[Depends(matrix_gate)])
|
||||
def get_277ca_ack_endpoint(ack_id: int) -> dict:
|
||||
"""Return one persisted 277CA ACK row with its parsed detail."""
|
||||
row = store.get_277ca_ack(ack_id)
|
||||
@@ -1054,7 +1055,7 @@ def _serialize_ta1_from_row(row: db.Ta1Ack) -> str:
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@app.get("/api/inbox/lanes")
|
||||
@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())
|
||||
@@ -1072,7 +1073,7 @@ def inbox_lanes():
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/inbox/candidates/{remit_id}/match")
|
||||
@app.post("/api/inbox/candidates/{remit_id}/match", dependencies=[Depends(matrix_gate)])
|
||||
def inbox_match_candidate(remit_id: str, body: dict):
|
||||
"""Manually link a remit to a claim."""
|
||||
claim_id = body.get("claim_id")
|
||||
@@ -1101,7 +1102,7 @@ def inbox_match_candidate(remit_id: str, body: dict):
|
||||
return {"ok": True, "claim_id": claim_id, "remit_id": remit_id}
|
||||
|
||||
|
||||
@app.post("/api/inbox/candidates/dismiss")
|
||||
@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."""
|
||||
pairs = body.get("pairs") or []
|
||||
@@ -1115,7 +1116,7 @@ def inbox_dismiss_candidates(body: dict):
|
||||
return {"ok": True, "dismissed_count": len(pairs)}
|
||||
|
||||
|
||||
@app.post("/api/inbox/rejected/resubmit")
|
||||
@app.post("/api/inbox/rejected/resubmit", dependencies=[Depends(matrix_gate)])
|
||||
def inbox_resubmit_rejected(
|
||||
request: Request,
|
||||
body: dict,
|
||||
@@ -1209,7 +1210,7 @@ def inbox_resubmit_rejected(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/inbox/export.csv")
|
||||
@app.get("/api/inbox/export.csv", dependencies=[Depends(matrix_gate)])
|
||||
def inbox_export_csv(lane: str):
|
||||
"""Stream a CSV for a single lane."""
|
||||
if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
|
||||
@@ -1262,7 +1263,7 @@ def _batch_summary_claim_count(rec: BatchRecord) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
@app.get("/api/batches")
|
||||
@app.get("/api/batches", dependencies=[Depends(matrix_gate)])
|
||||
def list_batches(
|
||||
request: Request,
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
@@ -1296,7 +1297,7 @@ def list_batches(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/batches/{batch_id}")
|
||||
@app.get("/api/batches/{batch_id}", dependencies=[Depends(matrix_gate)])
|
||||
def get_batch(batch_id: str) -> Any:
|
||||
rec = store.get(batch_id)
|
||||
if rec is None:
|
||||
@@ -1307,7 +1308,7 @@ def get_batch(batch_id: str) -> Any:
|
||||
return json.loads(rec.result.model_dump_json())
|
||||
|
||||
|
||||
@app.get("/api/claims")
|
||||
@app.get("/api/claims", dependencies=[Depends(matrix_gate)])
|
||||
def list_claims(
|
||||
request: Request,
|
||||
batch_id: str | None = Query(None),
|
||||
@@ -1418,7 +1419,7 @@ async def _tail_events(
|
||||
bus.unsubscribe(queue, kinds)
|
||||
|
||||
|
||||
@app.get("/api/claims/stream")
|
||||
@app.get("/api/claims/stream", dependencies=[Depends(matrix_gate)])
|
||||
async def claims_stream(
|
||||
request: Request,
|
||||
status: str | None = Query(None),
|
||||
@@ -1465,7 +1466,7 @@ async def claims_stream(
|
||||
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
||||
|
||||
|
||||
@app.get("/api/claims/{claim_id}")
|
||||
@app.get("/api/claims/{claim_id}", dependencies=[Depends(matrix_gate)])
|
||||
def get_claim_detail_endpoint(claim_id: str) -> dict:
|
||||
"""Return one claim with full drawer context (SP4).
|
||||
|
||||
@@ -1490,7 +1491,7 @@ def get_claim_detail_endpoint(claim_id: str) -> dict:
|
||||
return body
|
||||
|
||||
|
||||
@app.get("/api/claims/{claim_id}/serialize-837")
|
||||
@app.get("/api/claims/{claim_id}/serialize-837", dependencies=[Depends(matrix_gate)])
|
||||
def serialize_claim_as_837(claim_id: str):
|
||||
"""Return the claim as a regenerated X12 837P file (SP8).
|
||||
|
||||
@@ -1542,7 +1543,7 @@ def serialize_claim_as_837(claim_id: str):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/claims/{claim_id}/line-reconciliation")
|
||||
@app.get("/api/claims/{claim_id}/line-reconciliation", dependencies=[Depends(matrix_gate)])
|
||||
def get_claim_line_reconciliation(claim_id: str) -> dict:
|
||||
"""Per-line reconciliation view for the ClaimDrawer tab.
|
||||
|
||||
@@ -1739,7 +1740,7 @@ def _svc_to_dict(svc) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/reconciliation/unmatched")
|
||||
@app.get("/api/reconciliation/unmatched", dependencies=[Depends(matrix_gate)])
|
||||
def get_reconciliation_unmatched() -> dict:
|
||||
"""Return unmatched Claims (left) and unmatched Remittances (right).
|
||||
|
||||
@@ -1756,7 +1757,7 @@ def get_reconciliation_unmatched() -> dict:
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@app.get("/api/batch-diff")
|
||||
@app.get("/api/batch-diff", dependencies=[Depends(matrix_gate)])
|
||||
def get_batch_diff(
|
||||
a: str | None = Query(None),
|
||||
b: str | None = Query(None),
|
||||
@@ -1803,7 +1804,7 @@ def get_batch_diff(
|
||||
return diff_batches_to_wire(a_rec, b_rec)
|
||||
|
||||
|
||||
@app.post("/api/reconciliation/match")
|
||||
@app.post("/api/reconciliation/match", dependencies=[Depends(matrix_gate)])
|
||||
def post_reconciliation_match(body: dict) -> dict:
|
||||
"""Manually pair a Claim with a Remittance (operator override).
|
||||
|
||||
@@ -1848,7 +1849,7 @@ def post_reconciliation_match(body: dict) -> dict:
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/reconciliation/unmatch")
|
||||
@app.post("/api/reconciliation/unmatch", dependencies=[Depends(matrix_gate)])
|
||||
def post_reconciliation_unmatch(body: dict) -> dict:
|
||||
"""Remove the current match for a Claim; reset Claim to submitted.
|
||||
|
||||
@@ -1880,7 +1881,7 @@ def post_reconciliation_unmatch(body: dict) -> dict:
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/remittances")
|
||||
@app.get("/api/remittances", dependencies=[Depends(matrix_gate)])
|
||||
def list_remittances(
|
||||
request: Request,
|
||||
batch_id: str | None = Query(None),
|
||||
@@ -1919,7 +1920,7 @@ def list_remittances(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/remittances/stream")
|
||||
@app.get("/api/remittances/stream", dependencies=[Depends(matrix_gate)])
|
||||
async def remittances_stream(
|
||||
request: Request,
|
||||
payer: str | None = Query(None),
|
||||
@@ -1958,7 +1959,7 @@ async def remittances_stream(
|
||||
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
||||
|
||||
|
||||
@app.get("/api/remittances/{remittance_id}")
|
||||
@app.get("/api/remittances/{remittance_id}", dependencies=[Depends(matrix_gate)])
|
||||
def get_remittance(remittance_id: str) -> dict:
|
||||
"""Return one remittance with its labeled CAS ``adjustments`` array.
|
||||
|
||||
@@ -1975,7 +1976,7 @@ def get_remittance(remittance_id: str) -> dict:
|
||||
return body
|
||||
|
||||
|
||||
@app.get("/api/providers")
|
||||
@app.get("/api/providers", dependencies=[Depends(matrix_gate)])
|
||||
def list_providers(
|
||||
request: Request,
|
||||
npi: str | None = Query(None),
|
||||
@@ -2005,7 +2006,7 @@ def list_providers(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/activity")
|
||||
@app.get("/api/activity", dependencies=[Depends(matrix_gate)])
|
||||
def list_activity(
|
||||
request: Request,
|
||||
kind: str | None = Query(None),
|
||||
@@ -2037,7 +2038,7 @@ def list_activity(
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@app.get("/api/dashboard/summary")
|
||||
@app.get("/api/dashboard/summary", dependencies=[Depends(matrix_gate)])
|
||||
def dashboard_summary(
|
||||
months: int = Query(6, ge=1, le=24),
|
||||
top_providers: int = Query(4, ge=1, le=20),
|
||||
@@ -2061,7 +2062,7 @@ def dashboard_summary(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/activity/stream")
|
||||
@app.get("/api/activity/stream", dependencies=[Depends(matrix_gate)])
|
||||
async def activity_stream(
|
||||
request: Request,
|
||||
kind: str | None = Query(None),
|
||||
@@ -2125,7 +2126,7 @@ def _ack_to_ui(row) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/acks")
|
||||
@app.get("/api/acks", dependencies=[Depends(matrix_gate)])
|
||||
def list_acks_endpoint(
|
||||
request: Request,
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
@@ -2149,7 +2150,7 @@ def list_acks_endpoint(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/acks/{ack_id}")
|
||||
@app.get("/api/acks/{ack_id}", dependencies=[Depends(matrix_gate)])
|
||||
def get_ack_endpoint(ack_id: int) -> dict:
|
||||
"""Return one persisted ACK row with its parsed detail.
|
||||
|
||||
@@ -2289,7 +2290,7 @@ def _validate_eligibility_request(body: dict) -> tuple[ParseResult270, str]:
|
||||
return result, service_type_code
|
||||
|
||||
|
||||
@app.post("/api/eligibility/request")
|
||||
@app.post("/api/eligibility/request", dependencies=[Depends(matrix_gate)])
|
||||
def post_eligibility_request(body: dict) -> Any:
|
||||
"""Build a 270 eligibility inquiry from a small JSON body.
|
||||
|
||||
@@ -2315,7 +2316,7 @@ def post_eligibility_request(body: dict) -> Any:
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/eligibility/parse-271")
|
||||
@app.post("/api/eligibility/parse-271", dependencies=[Depends(matrix_gate)])
|
||||
async def post_eligibility_parse_271(
|
||||
file: UploadFile = File(...),
|
||||
) -> Any:
|
||||
@@ -2375,7 +2376,7 @@ async def post_eligibility_parse_271(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/clearhouse")
|
||||
@app.get("/api/clearhouse", dependencies=[Depends(matrix_gate)])
|
||||
def get_clearhouse():
|
||||
"""Return the singleton clearhouse config (dzinesco's identity, SFTP block, filename block)."""
|
||||
ch = store.get_clearhouse()
|
||||
@@ -2384,7 +2385,7 @@ def get_clearhouse():
|
||||
return json.loads(ch.model_dump_json())
|
||||
|
||||
|
||||
@app.post("/api/clearhouse/submit")
|
||||
@app.post("/api/clearhouse/submit", dependencies=[Depends(matrix_gate)])
|
||||
def submit_to_clearhouse(body: dict):
|
||||
"""Submit a batch of claims to the clearhouse (SFTP). SP9: stub.
|
||||
|
||||
@@ -2484,7 +2485,7 @@ def _serialize_claim_from_raw(claim_row, raw: dict) -> str:
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/config/providers")
|
||||
@app.get("/api/config/providers", dependencies=[Depends(matrix_gate)])
|
||||
def list_configured_providers(is_active: bool | None = Query(default=True)):
|
||||
"""List the configured provider rows (3 NPIs for SP9)."""
|
||||
return [json.loads(p.model_dump_json()) for p in store.list_providers(is_active=is_active)]
|
||||
@@ -2495,7 +2496,7 @@ def list_configured_providers(is_active: bool | None = Query(default=True)):
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@app.get("/api/admin/audit-log")
|
||||
@app.get("/api/admin/audit-log", dependencies=[Depends(matrix_gate)])
|
||||
def list_audit_log_endpoint(
|
||||
entity_type: str | None = Query(default=None),
|
||||
entity_id: str | None = Query(default=None),
|
||||
@@ -2537,7 +2538,7 @@ def list_audit_log_endpoint(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/admin/audit-log/verify")
|
||||
@app.get("/api/admin/audit-log/verify", dependencies=[Depends(matrix_gate)])
|
||||
def verify_audit_log_endpoint() -> Any:
|
||||
"""Walk the audit-log chain and verify every row's hash.
|
||||
|
||||
@@ -2556,7 +2557,7 @@ def verify_audit_log_endpoint() -> Any:
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/config/providers/{npi}")
|
||||
@app.get("/api/config/providers/{npi}", dependencies=[Depends(matrix_gate)])
|
||||
def get_configured_provider(npi: str):
|
||||
p = store.get_provider(npi)
|
||||
if p is None:
|
||||
@@ -2564,12 +2565,12 @@ def get_configured_provider(npi: str):
|
||||
return json.loads(p.model_dump_json())
|
||||
|
||||
|
||||
@app.get("/api/config/payers")
|
||||
@app.get("/api/config/payers", dependencies=[Depends(matrix_gate)])
|
||||
def list_configured_payers(is_active: bool | None = Query(default=True)):
|
||||
return [json.loads(p.model_dump_json()) for p in store.list_payers(is_active=is_active)]
|
||||
|
||||
|
||||
@app.get("/api/config/payers/{payer_id}/configs")
|
||||
@app.get("/api/config/payers/{payer_id}/configs", dependencies=[Depends(matrix_gate)])
|
||||
def list_payer_configs(payer_id: str):
|
||||
"""List all (transaction_type, config_json) blocks for a payer."""
|
||||
from cyclone import payers as payer_loader
|
||||
@@ -2586,7 +2587,7 @@ def list_payer_configs(payer_id: str):
|
||||
return configs
|
||||
|
||||
|
||||
@app.post("/api/admin/reload-config")
|
||||
@app.post("/api/admin/reload-config", dependencies=[Depends(matrix_gate)])
|
||||
def reload_config():
|
||||
"""Re-read ``config/payers.yaml`` and revalidate. Returns counts."""
|
||||
from cyclone import payers as payer_loader
|
||||
|
||||
@@ -102,3 +102,40 @@ def require_role(*allowed: Role):
|
||||
)
|
||||
return user
|
||||
return _dep
|
||||
|
||||
|
||||
async def matrix_gate(
|
||||
request: Request,
|
||||
user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""App-wide gate: requires auth, then enforces the PERMISSIONS matrix.
|
||||
|
||||
Behavior:
|
||||
* AUTH_DISABLED short-circuits (synthetic admin, no role check).
|
||||
* No session cookie → 401 from get_current_user.
|
||||
* Endpoint not in the matrix → 403 (fail-closed).
|
||||
* User role not allowed for (method, path) → 403.
|
||||
* Empty allowed-roles set (e.g. /api/healthz) → public, no role check.
|
||||
|
||||
Used as the single ``dependencies=[Depends(matrix_gate)]`` on every
|
||||
authenticated route in ``cyclone.api``. Centralizing the gate here
|
||||
means the matrix is the source of truth — no need to wire per-route
|
||||
``require_role(...)`` calls.
|
||||
"""
|
||||
if AUTH_DISABLED:
|
||||
return user
|
||||
method = request.method
|
||||
path = request.url.path
|
||||
roles = allowed_roles(method, path)
|
||||
if roles is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="forbidden",
|
||||
)
|
||||
user_role = user.get("role")
|
||||
if user_role not in {r.value for r in roles}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="forbidden",
|
||||
)
|
||||
return user
|
||||
|
||||
@@ -10,26 +10,51 @@ requires the engine to be initialized before any DB access, whereas the
|
||||
old in-memory store did not. Adding the init here keeps existing test
|
||||
modules (test_api.py, test_api_835.py, test_api_gets.py,
|
||||
test_api_parse_persists.py) working unchanged.
|
||||
|
||||
Auth posture: ``CYCLONE_AUTH_DISABLED`` defaults to ``"1"`` here so the
|
||||
existing test suite (700+ tests that don't log in) continues to pass
|
||||
unmodified. Tests in ``test_auth_*`` modules explicitly re-enable auth
|
||||
so they exercise the real ``get_current_user`` / ``matrix_gate`` paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
# Must be set before ``cyclone.api`` is imported so ``get_current_user``
|
||||
# / ``matrix_gate`` see the env var. The module-level ``AUTH_DISABLED``
|
||||
# flag in ``cyclone.auth.deps`` is flipped per-test below.
|
||||
os.environ.setdefault("CYCLONE_AUTH_DISABLED", "1")
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _auto_init_db(tmp_path, monkeypatch):
|
||||
def _auto_init_db(tmp_path, monkeypatch, request):
|
||||
"""Point CYCLONE_DB_URL at a per-test SQLite file and init the schema.
|
||||
|
||||
Also wires a fresh ``EventBus`` onto ``app.state`` because ``TestClient``
|
||||
does not invoke the FastAPI lifespan handler unless used as a context
|
||||
manager. The bus is reset between tests so subscribers don't leak.
|
||||
|
||||
Auth posture is set per-test from the test module's name: legacy
|
||||
tests (any module not starting with ``test_auth``) run with
|
||||
``AUTH_DISABLED=True`` so they hit the API without logging in;
|
||||
``test_auth_*`` modules run with ``AUTH_DISABLED=False`` so they
|
||||
exercise the real ``get_current_user`` / ``matrix_gate`` paths.
|
||||
"""
|
||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
||||
from cyclone import db
|
||||
from cyclone.api import app
|
||||
from cyclone.auth import deps as _auth_deps
|
||||
from cyclone.pubsub import EventBus
|
||||
|
||||
test_module = request.node.module.__name__
|
||||
if test_module.startswith("test_auth"):
|
||||
_auth_deps.AUTH_DISABLED = False
|
||||
else:
|
||||
_auth_deps.AUTH_DISABLED = True
|
||||
|
||||
db._reset_for_tests()
|
||||
db.init_db()
|
||||
app.state.event_bus = EventBus()
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Spot-check that existing endpoints now require auth (when AUTH_DISABLED is not set).
|
||||
|
||||
The conftest in ``tests/conftest.py`` flips ``AUTH_DISABLED=True`` for
|
||||
every test module that does NOT start with ``test_auth`` — this module
|
||||
deliberately is NOT named ``test_auth_*`` so it inherits the default
|
||||
disabled posture... wait, that's the opposite of what we want.
|
||||
|
||||
This module is named ``test_existing_endpoints_require_auth`` so the
|
||||
conftest will treat it as a legacy test and set ``AUTH_DISABLED=True``,
|
||||
bypassing the auth check entirely. To actually verify the gate, this
|
||||
test's ``client`` fixture flips ``AUTH_DISABLED`` to ``False``
|
||||
*just for this test*, then restores it afterwards. This way the
|
||||
rest of the suite still sees the disabled posture and existing
|
||||
tests keep passing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import delete
|
||||
|
||||
from cyclone.api import app
|
||||
from cyclone.auth import deps as _auth_deps
|
||||
from cyclone.auth.deps import AUTH_DISABLED
|
||||
from cyclone.db import Session as DbSession
|
||||
from cyclone.db import SessionLocal, User
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear():
|
||||
with SessionLocal()() as db:
|
||||
db.execute(delete(DbSession))
|
||||
db.execute(delete(User))
|
||||
db.commit()
|
||||
yield
|
||||
with SessionLocal()() as db:
|
||||
db.execute(delete(DbSession))
|
||||
db.execute(delete(User))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
# Force AUTH_DISABLED=False for these tests so the dep actually checks the cookie.
|
||||
original = AUTH_DISABLED
|
||||
_auth_deps.AUTH_DISABLED = False
|
||||
try:
|
||||
yield TestClient(app)
|
||||
finally:
|
||||
_auth_deps.AUTH_DISABLED = original
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method,path", [
|
||||
("GET", "/api/claims"),
|
||||
("GET", "/api/remittances"),
|
||||
("GET", "/api/providers"),
|
||||
("GET", "/api/batches"),
|
||||
("GET", "/api/dashboard/summary"),
|
||||
("GET", "/api/activity"),
|
||||
])
|
||||
def test_existing_get_endpoints_require_auth(client, method, path):
|
||||
resp = client.request(method, path)
|
||||
assert resp.status_code == 401, f"{method} {path} returned {resp.status_code}"
|
||||
|
||||
|
||||
def test_health_is_public(client):
|
||||
"""``/api/health`` is the public healthcheck and must remain reachable."""
|
||||
resp = client.get("/api/health")
|
||||
assert resp.status_code != 401, f"/api/health returned {resp.status_code}"
|
||||
Reference in New Issue
Block a user