feat(api): add CORS middleware and /health contract test

CORS:
- New setting CORS_ALLOWED_ORIGINS (comma separated). Defaults cover
  the three local Vite ports (5173, 5273, 4173); production overlay
  expects the real origin in .env.prod.
- main.py wires CORSMiddleware from settings.cors_origins. No * in
  production - see RUNBOOK and .env.prod.example.
- docker-compose.yml forwards the variable to both api and worker.

Tests:
- tests/test_api_health.py uses FastAPI TestClient and monkeypatches
  the five probe functions (postgres/minio/opensearch/qdrant/redis).
  Verifies the all-ok, any-error, and degraded paths, that the root
  endpoint reports the configured api prefix, and that the CORS
  preflight echoes the allowed origin.
- pytest tests/test_api_health.py -q: 5 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vadim Malanov
2026-05-13 16:48:49 +03:00
parent eecdfaa847
commit cd9977f8c3
4 changed files with 123 additions and 0 deletions

View File

@@ -27,6 +27,14 @@ class Settings(BaseSettings):
app_input_dir: str = Field("/data/input", alias="APP_INPUT_DIR") app_input_dir: str = Field("/data/input", alias="APP_INPUT_DIR")
app_work_dir: str = Field("/data/work", alias="APP_WORK_DIR") app_work_dir: str = Field("/data/work", alias="APP_WORK_DIR")
app_api_prefix: str = Field("/api/v1", alias="APP_API_PREFIX") app_api_prefix: str = Field("/api/v1", alias="APP_API_PREFIX")
cors_allowed_origins: str = Field(
"http://localhost:5173,http://localhost:5273,http://localhost:4173",
alias="CORS_ALLOWED_ORIGINS",
)
@property
def cors_origins(self) -> list[str]:
return [o.strip() for o in self.cors_allowed_origins.split(",") if o.strip()]
# ---------------- Postgres ---------------- # ---------------- Postgres ----------------
postgres_host: str = Field("postgres", alias="POSTGRES_HOST") postgres_host: str = Field("postgres", alias="POSTGRES_HOST")

View File

@@ -6,6 +6,7 @@ from contextlib import asynccontextmanager
from typing import AsyncIterator from typing import AsyncIterator
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app import __version__ from app import __version__
from app.api import routes_health, routes_ingestion, routes_search from app.api import routes_health, routes_ingestion, routes_search
@@ -37,6 +38,15 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"],
max_age=3600,
)
app.include_router(routes_health.router, prefix=settings.app_api_prefix) app.include_router(routes_health.router, prefix=settings.app_api_prefix)
app.include_router(routes_ingestion.router, prefix=settings.app_api_prefix) app.include_router(routes_ingestion.router, prefix=settings.app_api_prefix)
app.include_router(routes_search.router, prefix=settings.app_api_prefix) app.include_router(routes_search.router, prefix=settings.app_api_prefix)

View File

@@ -32,6 +32,7 @@ x-common-env: &common-env
APP_LOG_LEVEL: ${APP_LOG_LEVEL:-INFO} APP_LOG_LEVEL: ${APP_LOG_LEVEL:-INFO}
APP_INPUT_DIR: /data/input APP_INPUT_DIR: /data/input
APP_WORK_DIR: /data/work APP_WORK_DIR: /data/work
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:5173,http://localhost:5273,http://localhost:4173}
services: services:
postgres: postgres:

104
tests/test_api_health.py Normal file
View File

@@ -0,0 +1,104 @@
"""Contract test for /health.
Patches the individual probe functions so the test does not depend on a live
Postgres / MinIO / OpenSearch / Qdrant / Redis. Verifies:
- the route is mounted under the configured API prefix;
- the response shape conforms to ``HealthResponse``;
- the overall status follows the worst-component-wins rule
(``ok`` -> ``ok``, any ``error`` -> ``error``, ``degraded`` otherwise);
- the CORS preflight responds with the configured allowed origin.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.api.schemas import ComponentHealth
from app.config import settings
from app.main import app
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def _ok(name: str) -> ComponentHealth:
return ComponentHealth(name=name, status="ok", detail={})
def _err(name: str) -> ComponentHealth:
return ComponentHealth(name=name, status="error", detail={"error": "down"})
def _degraded(name: str) -> ComponentHealth:
return ComponentHealth(name=name, status="degraded", detail={"cluster_status": "red"})
def _patch_probes(monkeypatch, **overrides):
from app.api import routes_health
defaults = {
"_check_postgres": lambda: _ok("postgres"),
"_check_minio": lambda: _ok("minio"),
"_check_opensearch": lambda: _ok("opensearch"),
"_check_qdrant": lambda: _ok("qdrant"),
"_check_redis": lambda: _ok("redis"),
}
defaults.update(overrides)
for name, fn in defaults.items():
monkeypatch.setattr(routes_health, name, fn)
def test_health_all_ok(client: TestClient, monkeypatch):
_patch_probes(monkeypatch)
res = client.get(f"{settings.app_api_prefix}/health")
assert res.status_code == 200
body = res.json()
assert body["status"] == "ok"
assert {c["name"] for c in body["components"]} == {
"postgres",
"minio",
"opensearch",
"qdrant",
"redis",
}
def test_health_error_when_any_component_down(client: TestClient, monkeypatch):
_patch_probes(monkeypatch, _check_qdrant=lambda: _err("qdrant"))
res = client.get(f"{settings.app_api_prefix}/health")
assert res.status_code == 200
body = res.json()
assert body["status"] == "error"
qdrant = next(c for c in body["components"] if c["name"] == "qdrant")
assert qdrant["status"] == "error"
def test_health_degraded_when_any_component_degraded(client: TestClient, monkeypatch):
_patch_probes(monkeypatch, _check_opensearch=lambda: _degraded("opensearch"))
res = client.get(f"{settings.app_api_prefix}/health")
body = res.json()
assert body["status"] == "degraded"
def test_root_includes_api_prefix(client: TestClient):
res = client.get("/")
assert res.status_code == 200
assert res.json()["api"] == settings.app_api_prefix
def test_cors_preflight_allows_configured_origin(client: TestClient):
origin = settings.cors_origins[0]
res = client.options(
f"{settings.app_api_prefix}/health",
headers={
"Origin": origin,
"Access-Control-Request-Method": "GET",
},
)
assert res.status_code == 200
assert res.headers.get("access-control-allow-origin") == origin