diff --git a/app/config.py b/app/config.py index 400b08a..ed8fe22 100644 --- a/app/config.py +++ b/app/config.py @@ -27,6 +27,14 @@ class Settings(BaseSettings): app_input_dir: str = Field("/data/input", alias="APP_INPUT_DIR") app_work_dir: str = Field("/data/work", alias="APP_WORK_DIR") 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_host: str = Field("postgres", alias="POSTGRES_HOST") diff --git a/app/main.py b/app/main.py index 73687a3..5a896af 100644 --- a/app/main.py +++ b/app/main.py @@ -6,6 +6,7 @@ from contextlib import asynccontextmanager from typing import AsyncIterator from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from app import __version__ from app.api import routes_health, routes_ingestion, routes_search @@ -37,6 +38,15 @@ app = FastAPI( 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_ingestion.router, prefix=settings.app_api_prefix) app.include_router(routes_search.router, prefix=settings.app_api_prefix) diff --git a/docker-compose.yml b/docker-compose.yml index cc70e0e..685fffd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ x-common-env: &common-env APP_LOG_LEVEL: ${APP_LOG_LEVEL:-INFO} APP_INPUT_DIR: /data/input APP_WORK_DIR: /data/work + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:5173,http://localhost:5273,http://localhost:4173} services: postgres: diff --git a/tests/test_api_health.py b/tests/test_api_health.py new file mode 100644 index 0000000..60f206c --- /dev/null +++ b/tests/test_api_health.py @@ -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