diff --git a/api/src/core/nats.py b/api/src/core/nats.py index d18530d..68dcfe0 100644 --- a/api/src/core/nats.py +++ b/api/src/core/nats.py @@ -288,7 +288,7 @@ class NatsConnection: Без `subject` — `NATS_TABULAR_REQUEST_SUBJECT` настроек (по умолчанию `files.tabular_uploaded`). """ - subject = subject or self._settings.NATS_TABULAR_REQUEST_SUBJECT + subject = subject if subject is not None else self._settings.NATS_TABULAR_REQUEST_SUBJECT if not self.is_connected: raise NatsNotConnectedError(f"Нет соединения с NATS: подписка на {subject} невозможна") diff --git a/api/tests/unit/test_app.py b/api/tests/unit/test_app.py index 78d7e20..a5442ab 100644 --- a/api/tests/unit/test_app.py +++ b/api/tests/unit/test_app.py @@ -16,6 +16,7 @@ from typing import Any import httpx import pytest from loguru import logger +from pydantic import SecretStr from src.contract import NATS_MSG_ID_HEADER, RESULT_SUBJECT from src.core.clients import S3Storage @@ -210,6 +211,70 @@ async def test_nats_enabled_but_not_connected_reports_down(patched_app: Any, mon assert patched_app.app.state.worker is not None +# --- subject'ы из настроек, JWT вне ответа, лог старта, передача result_subject воркеру ------------------------------ +CUSTOM_SUBJECTS = { + "NATS_TABULAR_REQUEST_SUBJECT": "test.files.tabular_uploaded", + "NATS_RESULT_SUBJECT": "test.files.tabular_parsed", +} +SECRET_JWT = "eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.healthcheck-secret.sig" + + +@pytest.fixture +def custom_nats_settings(patched_app: Any, monkeypatch: pytest.MonkeyPatch) -> Any: + """Подменяет `settings.OPENBAO.NATS` копией с нестандартными subject'ами и непустым JWT.""" + nats = patched_app.settings.OPENBAO.NATS.model_copy(update={**CUSTOM_SUBJECTS, "NATS_JWT": SecretStr(SECRET_JWT)}) + monkeypatch.setattr(patched_app.settings.OPENBAO, "NATS", nats) + return nats + + +@pytest.mark.parametrize(("nats_enabled", "state"), [(False, "off"), (True, "down")]) +async def test_healthcheck_reports_configured_subjects_and_hides_jwt( + patched_app: Any, + custom_nats_settings: Any, + monkeypatch: pytest.MonkeyPatch, + nats_enabled: bool, + state: str, +) -> None: + monkeypatch.setattr(patched_app.settings, "NATS_ENABLED", nats_enabled) + FakeNatsConnection.connect_on_start = False # state=down: NATS включён, но связи нет + + async with running_app(patched_app) as client: + response = await client.get("/healthcheck") + + body = response.json() + assert body["nats"] == state + assert body["subjects"] == { + "tabular_request": "test.files.tabular_uploaded", + "result": "test.files.tabular_parsed", + } + assert SECRET_JWT not in response.text + assert SECRET_JWT.split(".")[1] not in response.text + + +async def test_startup_logs_configured_subjects( + patched_app: Any, custom_nats_settings: Any, monkeypatch: pytest.MonkeyPatch, log_records: list[Any] +) -> None: + monkeypatch.setattr(patched_app.settings, "NATS_ENABLED", False) + + async with running_app(patched_app): + pass + + messages = [record["message"] for record in log_records if "Subject'ы NATS" in record["message"]] + assert len(messages) == 1 + assert "test.files.tabular_uploaded" in messages[0] + assert "test.files.tabular_parsed" in messages[0] + assert SECRET_JWT not in messages[0] + + +async def test_build_worker_passes_configured_result_subject( + patched_app: Any, custom_nats_settings: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """Воркер публикует ack и результат в настроенный `NATS_RESULT_SUBJECT`, а не в константу контракта.""" + nats, worker = patched_app._build_worker(FakeStorage([])) + assert nats.settings.NATS_RESULT_SUBJECT == "test.files.tabular_parsed" + assert worker._result_subject == "test.files.tabular_parsed" # noqa: SLF001 - проверяем проводку настроек + + # --- проверка бакета при старте (_connect_s3) ------------------------------------------------------------------------ @pytest.fixture def log_records() -> Iterator[list[Any]]: diff --git a/api/tests/unit/test_config.py b/api/tests/unit/test_config.py index ce5277b..b8155ca 100644 --- a/api/tests/unit/test_config.py +++ b/api/tests/unit/test_config.py @@ -86,7 +86,7 @@ def test_service_name_other_than_contract_value_is_rejected(minimal_settings: Se minimal_settings("SERVICE_NAME=scan-gateway\n") -def test_nats_subjects_default_to_contract_names(settings_from_env) -> None: +def test_nats_subjects_default_to_contract_names(settings_from_env: SettingsFromEnvFactory) -> None: nats = settings_from_env().OPENBAO.NATS assert nats.NATS_TABULAR_REQUEST_SUBJECT == "files.tabular_uploaded" assert nats.NATS_IMAGE_REQUEST_SUBJECT == "files.img_uploaded" @@ -94,7 +94,7 @@ def test_nats_subjects_default_to_contract_names(settings_from_env) -> None: assert nats.request_subjects() == {"tabular": "files.tabular_uploaded", "image": "files.img_uploaded"} -def test_nats_subjects_from_openbao(settings_from_env) -> None: +def test_nats_subjects_from_openbao(settings_from_env: SettingsFromEnvFactory) -> None: nats = settings_from_env( OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT="test.files.tabular_uploaded", OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT="test.files.img_uploaded", @@ -108,17 +108,29 @@ def test_nats_subjects_from_openbao(settings_from_env) -> None: "value", ["", "files.*", "files.>", ">", "files..tabular", ".files", "files.", "files tabular", "files.tabular_uploaded "], ) -def test_invalid_subject_rejected(settings_from_env, value: str) -> None: - with pytest.raises(ValidationError, match="NATS_RESULT_SUBJECT"): - settings_from_env(OPENBAO__NATS__NATS_RESULT_SUBJECT=value) +@pytest.mark.parametrize("key", ["NATS_TABULAR_REQUEST_SUBJECT", "NATS_IMAGE_REQUEST_SUBJECT", "NATS_RESULT_SUBJECT"]) +def test_invalid_subject_rejected(settings_from_env: SettingsFromEnvFactory, key: str, value: str) -> None: + with pytest.raises(ValidationError, match=key): + settings_from_env(**{f"OPENBAO__NATS__{key}": value}) -def test_equal_subjects_rejected(settings_from_env) -> None: - with pytest.raises(ValidationError, match="NATS_TABULAR_REQUEST_SUBJECT и NATS_RESULT_SUBJECT совпадают"): - settings_from_env(OPENBAO__NATS__NATS_RESULT_SUBJECT="files.tabular_uploaded") +@pytest.mark.parametrize( + ("first", "second"), + [ + ("NATS_TABULAR_REQUEST_SUBJECT", "NATS_IMAGE_REQUEST_SUBJECT"), + ("NATS_TABULAR_REQUEST_SUBJECT", "NATS_RESULT_SUBJECT"), + ("NATS_IMAGE_REQUEST_SUBJECT", "NATS_RESULT_SUBJECT"), + ], +) +def test_equal_subjects_rejected(settings_from_env: SettingsFromEnvFactory, first: str, second: str) -> None: + """Любая пара из трёх subject'ов не может совпадать; сообщение называет оба ключа.""" + with pytest.raises(ValidationError, match=f"{first} и {second} совпадают"): + settings_from_env( + **{f"OPENBAO__NATS__{first}": "test.same_subject", f"OPENBAO__NATS__{second}": "test.same_subject"} + ) -def test_nats_secrets_hidden_in_repr(settings_from_env) -> None: +def test_nats_secrets_hidden_in_repr(settings_from_env: SettingsFromEnvFactory) -> None: settings = settings_from_env(OPENBAO__NATS__NATS_JWT=JWT, OPENBAO__NATS__NATS_SEED=SEED) assert settings.OPENBAO.NATS.NATS_JWT.get_secret_value() == JWT assert JWT not in repr(settings) and SEED not in repr(settings) @@ -135,7 +147,9 @@ MINIO_KEYS = ("MINIO_URL", "MINIO_ADMIN_LOGIN", "MINIO_ADMIN_PASSWORD", "MINIO_B tuple(f"OPENBAO__MINIO__{key}" for key in MINIO_KEYS), # нет группы MINIO: в ошибке — вход всего OPENBAO ], ) -def test_validation_error_hides_nats_secrets(settings_from_env, missing: tuple[str, ...]) -> None: +def test_validation_error_hides_nats_secrets( + settings_from_env: SettingsFromEnvFactory, missing: tuple[str, ...] +) -> None: """Ошибка «нет поля» печатает вход всей группы — JWT и seed туда попадать не должны (Review Focus 1). pydantic в тексте ошибки усекает значения (`'SUACSSL3UA...PQYYYCU3VY'`), поэтому полный секрет там не появляется @@ -147,3 +161,16 @@ def test_validation_error_hides_nats_secrets(settings_from_env, missing: tuple[s for secret in (JWT, SEED): for fragment in (secret[:10], secret[-10:]): assert fragment not in text + + +def test_invalid_subject_error_hides_nats_secrets(settings_from_env: SettingsFromEnvFactory) -> None: + """Невалидный subject при заданных JWT/seed: ошибка валидации печатает вход группы NATS — секретов там нет.""" + with pytest.raises(ValidationError) as exc_info: + settings_from_env( + OPENBAO__NATS__NATS_JWT=JWT, OPENBAO__NATS__NATS_SEED=SEED, OPENBAO__NATS__NATS_RESULT_SUBJECT="files.*" + ) + text = str(exc_info.value) + assert "NATS_RESULT_SUBJECT" in text + for secret in (JWT, SEED): + for fragment in (secret[:10], secret[-10:]): + assert fragment not in text diff --git a/openspec/changes/nats-env-config/tasks.md b/openspec/changes/nats-env-config/tasks.md index e2f855d..15df50a 100644 --- a/openspec/changes/nats-env-config/tasks.md +++ b/openspec/changes/nats-env-config/tasks.md @@ -1,18 +1,18 @@ ## 1. Тесты -- [ ] 1.1 Unit: умолчания subject'ов, чтение `OPENBAO__NATS__NATS_*_SUBJECT`, отказ на `*`/`>`/пробеле/пустом токене и +- [x] 1.1 Unit: умолчания subject'ов, чтение `OPENBAO__NATS__NATS_*_SUBJECT`, отказ на `*`/`>`/пробеле/пустом токене и совпадающих именах; `SecretStr` для `NATS_JWT`/`NATS_SEED`, отсутствие значений в ошибке валидации и `repr` -- [ ] 1.2 Unit: `/healthcheck.subjects` (`tabular_request`, `result`) при `nats = ok | down | off`; лог старта с subject'ами +- [x] 1.2 Unit: `/healthcheck.subjects` (`tabular_request`, `result`) при `nats = ok | down | off`; лог старта с subject'ами - [ ] 1.3 Тесты roundtrip (`tests/integration/test_roundtrip.py`) — уникальные subject'ы через настройки ## 2. Реализация -- [ ] 2.1 `core/config.py`: поля и валидация `NATSSettings`, `SecretStr`, `hide_input_in_errors=True` -- [ ] 2.2 Колбэки nkeys — `get_secret_value()` -- [ ] 2.3 Подписка и публикация по настройкам; константы `contract/subjects.py` — умолчания -- [ ] 2.4 `/healthcheck.subjects`, лог при старте +- [x] 2.1 `core/config.py`: поля и валидация `NATSSettings`, `SecretStr`, `hide_input_in_errors=True` +- [x] 2.2 Колбэки nkeys — `get_secret_value()` +- [x] 2.3 Подписка и публикация по настройкам; константы `contract/subjects.py` — умолчания +- [x] 2.4 `/healthcheck.subjects`, лог при старте ## 3. Документы -- [ ] 3.1 `api/.env.example`, README (переменные NATS, `/healthcheck`) +- [x] 3.1 `api/.env.example`, README (переменные NATS, `/healthcheck`) - [ ] 3.2 `make check-full` зелёный