feat(api): harden CORS and surface 500/409 errors with proper CORS headers
Three independent improvements that fix real browser-facing bugs: 1. CORS: allow 127.0.0.1:5173 in addition to localhost:5173. Both resolve to the same Vite dev server but CORS treats them as distinct origins, so tabs opened via the IP form silently break. 2. CORS: support CYCLONE_ALLOWED_ORIGINS env var (comma-separated) for LAN / staging hosts. The middleware reads it at module import. 3. Catch-all exception handler: returns JSON 500 with CORS headers instead of a bare Uvicorn text/plain response. Without this, any unhandled exception is misreported by browsers as a CORS error because the body can't be read without the allow-origin header. 4. IntegrityError → 409: when (batch_id, patient_control_number) is UNIQUE-constrained and a duplicate collides, return 409 with the batch id instead of letting the exception 500. Same problem as (3) for the most common ingest failure mode. Tests added: - test_cors_headers_present_for_loopback_ip - test_cors_extra_origins_via_env (uses importlib.reload because the allow-list is built at module import)
This commit is contained in:
@@ -160,3 +160,51 @@ def test_cors_headers_present(client: TestClient):
|
||||
)
|
||||
assert resp.headers.get("access-control-allow-origin") == "http://localhost:5173"
|
||||
assert "POST" in resp.headers.get("access-control-allow-methods", "").upper()
|
||||
|
||||
|
||||
def test_cors_headers_present_for_loopback_ip(client: TestClient):
|
||||
# ``http://127.0.0.1:5173`` is a distinct origin from
|
||||
# ``http://localhost:5173`` per the CORS spec, even though both resolve
|
||||
# to the same Vite dev server. Both must be allow-listed or tabs opened
|
||||
# via the IP form silently break.
|
||||
resp = client.options(
|
||||
"/api/parse-837",
|
||||
headers={
|
||||
"Origin": "http://127.0.0.1:5173",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "content-type",
|
||||
},
|
||||
)
|
||||
assert resp.headers.get("access-control-allow-origin") == "http://127.0.0.1:5173"
|
||||
|
||||
|
||||
def test_cors_extra_origins_via_env(client: TestClient, monkeypatch):
|
||||
# LAN / staging hosts opt in via CYCLONE_ALLOWED_ORIGINS. The env var
|
||||
# is a comma-separated list; the middleware must reflect each entry.
|
||||
# The allow-list is built at module import, so we re-execute the
|
||||
# module under the env var and build a TestClient against the
|
||||
# reloaded app.
|
||||
monkeypatch.setenv(
|
||||
"CYCLONE_ALLOWED_ORIGINS", "http://192.168.1.42:5173,https://staging.example.com"
|
||||
)
|
||||
import importlib
|
||||
from cyclone import api as api_module
|
||||
from fastapi.testclient import TestClient as _TC
|
||||
importlib.reload(api_module)
|
||||
try:
|
||||
with _TC(api_module.app) as tc:
|
||||
for origin in ("http://192.168.1.42:5173", "https://staging.example.com"):
|
||||
resp = tc.options(
|
||||
"/api/parse-837",
|
||||
headers={
|
||||
"Origin": origin,
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "content-type",
|
||||
},
|
||||
)
|
||||
assert resp.headers.get("access-control-allow-origin") == origin
|
||||
finally:
|
||||
monkeypatch.delenv("CYCLONE_ALLOWED_ORIGINS", raising=False)
|
||||
# Reload once more so the module-level allow-list returns to its
|
||||
# default for any test that imports `cyclone.api` after this one.
|
||||
importlib.reload(api_module)
|
||||
|
||||
Reference in New Issue
Block a user