diff --git a/api/src/api/router.py b/api/src/api/router.py index af9233c..91129c5 100644 --- a/api/src/api/router.py +++ b/api/src/api/router.py @@ -1,3 +1,3 @@ from fastapi import APIRouter -router = APIRouter() +router = APIRouter(prefix="/api/v1") diff --git a/api/src/app.py b/api/src/app.py index f8136c7..f3ff93e 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -1,7 +1,9 @@ """Точка входа FastAPI. Каркас из шаблона команды: инфраструктура без доменного кода. -Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT), SMB (горячие папки ИС Сканирование), -middleware (gzip, proxy headers, CORS, РАИСА при DEBUG=False), healthcheck, раздача собранного SPA. +Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT; пустые NATS_SEED/NATS_JWT — без учётных данных; +подключение при NATS_ENABLED=true независимо от DEBUG), SMB (горячие папки ИС Сканирование), middleware (gzip, proxy +headers, CORS, РАИСА при DEBUG=False), healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA. +Недоступность S3 или NATS при старте не роняет приложение: ошибка логируется, healthcheck показывает состояние. Что добавляет Срез 0: роутеры в src/api/router.py, outbox-паблишер и consumer recognition.result (docs/context/03-architecture.md), зависимость require(permission). """ @@ -11,10 +13,11 @@ import os from contextlib import asynccontextmanager from datetime import UTC, datetime from http import HTTPStatus +from typing import Any import nkeys import smbclient -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.exceptions import HTTPException, StarletteHTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles @@ -26,7 +29,7 @@ from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from src.api.router import router from src.core.clients import S3Storage -from src.core.config import get_settings +from src.core.config import NATSSettings, get_settings from src.core.exception_handlers import register_exception_handlers settings = get_settings() @@ -36,19 +39,31 @@ UP_TIME = datetime.now(UTC) # --- NATS: аутентификация по nkeys/JWT --------------------------------------------------------- -# Пустой NATS_SEED — подключение без nkeys (разработка, тесты); подключение переедет в core/nats.py (задача 22) -kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode("utf-8")) if settings.OPENBAO.NATS.NATS_SEED else None +def _build_nats_auth(nats_settings: NATSSettings) -> dict[str, Any]: + """Параметры аутентификации для ``NATS.connect``. + Пустые NATS_SEED и NATS_JWT — подключение без учётных данных (docker compose, тесты): ``{}``. Заданы оба — + колбэки nkeys/JWT, как в шаблоне. Задан только один — ошибка конфигурации. Подключение переедет в core/nats.py + (задача 22). + """ + seed = nats_settings.NATS_SEED + jwt = nats_settings.NATS_JWT + if not seed and not jwt: + return {} + if not seed or not jwt: + raise RuntimeError("Неполные учётные данные NATS: NATS_SEED и NATS_JWT задаются вместе или не задаются вовсе") -def jwt_cb(): - return settings.OPENBAO.NATS.NATS_JWT.encode("utf-8") + keypair = nkeys.from_seed(seed.encode("utf-8")) + def user_jwt_cb() -> bytes: + return jwt.encode("utf-8") -def sig_cb(nonce): - assert kp is not None - if isinstance(nonce, str): - nonce = nonce.encode("utf-8") - return base64.b64encode(kp.sign(nonce)) + def signature_cb(nonce: str | bytes) -> bytes: + if isinstance(nonce, str): + nonce = nonce.encode("utf-8") + return base64.b64encode(keypair.sign(nonce)) + + return {"user_jwt_cb": user_jwt_cb, "signature_cb": signature_cb} # --- РАИСА: защищённый API (только вне DEBUG) --------------------------------------------------- @@ -81,6 +96,52 @@ def _normalize_root_path(value: str) -> str: _APP_ROOT_PATH = _normalize_root_path(settings.ROOT_PATH) +async def _connect_s3() -> S3Storage | None: + """Клиент S3 с проверкой бакета; при недоступности — ``None`` (приложение всё равно стартует).""" + s3_client = Minio( + settings.OPENBAO.MINIO.MINIO_URL, + access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN, + secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD, + cert_check=False, + ) + try: + bucket_exists = await s3_client.bucket_exists(settings.OPENBAO.MINIO.MINIO_BUCKET) + except Exception as exc: + logger.exception( + "Не удалось проверить наличие бакета. error_category=s3_error, error_type={error_type}, error={error}", + error_type=type(exc).__name__, + error=repr(exc), + ) + await s3_client.close_session() + return None + logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists) + return S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET) + + +async def _connect_nats() -> NATS | None: + """Подключение к NATS при NATS_ENABLED=true; при ошибке — ``None`` (healthcheck покажет ``down``).""" + if not settings.NATS_ENABLED: + logger.info("NATS выключен (NATS_ENABLED=false)") + return None + nc = NATS() + try: + await nc.connect( + settings.OPENBAO.NATS.NATS_URL, + max_reconnect_attempts=-1, + reconnect_time_wait=2, + **_build_nats_auth(settings.OPENBAO.NATS), + ) + except Exception as exc: + logger.exception( + "Не удалось подключиться к NATS. error_category=nats_error, error_type={error_type}, error={error}", + error_type=type(exc).__name__, + error=repr(exc), + ) + return None + logger.info("Подключение к NATS произошло") + return nc + + @asynccontextmanager async def lifespan(app: FastAPI): app.state.nats_subs = [] @@ -94,42 +155,11 @@ async def lifespan(app: FastAPI): except Exception: logger.exception("Ошибка при регистрации сессии SMB") - s3_client = Minio( - settings.OPENBAO.MINIO.MINIO_URL, - access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN, - secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD, - cert_check=False, - ) try: - bucket_exists = await s3_client.bucket_exists(settings.OPENBAO.MINIO.MINIO_BUCKET) - logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists) - except Exception as exc: - logger.exception( - "Не удалось проверить наличие бакета. error_category=s3_error, error_type={error_type}, error={error}", - error_type=type(exc).__name__, - error=repr(exc), - ) - return - app.state.storage = S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET) + app.state.storage = await _connect_s3() + app.state.nats = await _connect_nats() + # Подписки (recognition.result) и outbox-паблишер — Срез 0, см. docs/context/03-architecture.md - nc = NATS() - if not settings.DEBUG: - try: - await nc.connect( - settings.OPENBAO.NATS.NATS_URL, user_jwt_cb=jwt_cb, signature_cb=sig_cb, max_reconnect_attempts=2 - ) - logger.info("Подключение к NATS произошло") - except Exception as exc: - logger.exception( - "Не удалось подключиться к NATS. error_category=nats_error, error_type={error_type}, error={error}", - error_type=type(exc).__name__, - error=repr(exc), - ) - return - app.state.nats = nc - # Подписки (recognition.result) и outbox-паблишер — Срез 0, см. docs/context/03-architecture.md - - try: logger.info("Приложение запущено") yield finally: @@ -137,7 +167,7 @@ async def lifespan(app: FastAPI): smbclient.reset_connection_cache() for sub in app.state.nats_subs: await sub.unsubscribe() - if app.state.nats and not settings.DEBUG: + if app.state.nats: await app.state.nats.close() if app.state.storage: await app.state.storage.client.close_session() @@ -147,8 +177,13 @@ app = FastAPI(title="АРМ КА", version=VERSION, root_path=settings.ROOT_PATH @app.get("/healthcheck", status_code=200) -async def healthcheck(): - return {"uptime": UP_TIME, "version": VERSION} +async def healthcheck(request: Request): + """Живость приложения. ``nats``: ``off`` — NATS выключен, ``ok`` — подключён, ``down`` — включён, но нет связи.""" + nats_status = "off" + if settings.NATS_ENABLED: + nc = getattr(request.app.state, "nats", None) + nats_status = "ok" if nc is not None and nc.is_connected else "down" + return {"uptime": UP_TIME, "version": VERSION, "nats": nats_status} app.include_router(router) diff --git a/api/tests/test_app.py b/api/tests/test_app.py new file mode 100644 index 0000000..601ddff --- /dev/null +++ b/api/tests/test_app.py @@ -0,0 +1,95 @@ +"""Быстрые тесты каркаса приложения: аутентификация NATS, /healthcheck, префикс API (без БД и NATS).""" + +import importlib +import os +from http import HTTPStatus +from types import SimpleNamespace + +import httpx +import nkeys +import pytest + +from src.api.router import router +from src.core.config import NATSSettings + +# src/__init__.py реэкспортирует объект app под именем модуля, поэтому модуль берём через importlib +app_module = importlib.import_module("src.app") + + +def _nats_settings(seed: str = "", jwt: str = "") -> NATSSettings: + return NATSSettings(NATS_URL="nats://localhost:4222", NATS_SEED=seed, NATS_JWT=jwt) + + +def _valid_seed() -> str: + return nkeys.encode_seed(os.urandom(32), nkeys.PREFIX_BYTE_USER).decode("utf-8") + + +def test_build_nats_auth_empty_credentials_gives_no_kwargs(): + assert app_module._build_nats_auth(_nats_settings()) == {} + + +@pytest.mark.parametrize( + ("seed", "jwt"), + [("SU-only-seed", ""), ("", "only-jwt")], +) +def test_build_nats_auth_partial_credentials_is_config_error(seed: str, jwt: str): + with pytest.raises(RuntimeError, match="NATS_SEED"): + app_module._build_nats_auth(_nats_settings(seed, jwt)) + + +def test_build_nats_auth_valid_credentials_give_callbacks(): + seed = _valid_seed() + auth = app_module._build_nats_auth(_nats_settings(seed, "header.payload.signature")) + + assert set(auth) == {"user_jwt_cb", "signature_cb"} + assert auth["user_jwt_cb"]() == b"header.payload.signature" + signature = auth["signature_cb"](b"nonce") + assert isinstance(signature, bytes) + assert signature + + +@pytest.fixture +async def client(): + # Без lifespan: app.state.nats не установлен — healthcheck обязан это пережить + transport = httpx.ASGITransport(app=app_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +async def test_healthcheck_nats_off_when_disabled(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(app_module.settings, "NATS_ENABLED", False) + + response = await client.get("/healthcheck") + + assert response.status_code == HTTPStatus.OK + body = response.json() + assert body["nats"] == "off" + assert {"uptime", "version"} <= set(body) + + +async def test_healthcheck_nats_down_when_enabled_without_connection( + client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True) + + response = await client.get("/healthcheck") + + assert response.status_code == HTTPStatus.OK + assert response.json()["nats"] == "down" + + +@pytest.mark.parametrize(("connected", "expected"), [(True, "ok"), (False, "down")]) +async def test_healthcheck_nats_follows_connection_state( + client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, connected: bool, expected: str +): + monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True) + monkeypatch.setattr(app_module.app.state, "nats", SimpleNamespace(is_connected=connected), raising=False) + + response = await client.get("/healthcheck") + + assert response.status_code == HTTPStatus.OK + assert response.json()["nats"] == expected + + +def test_router_prefix(): + assert router.prefix == "/api/v1"