diff --git a/api/tests/test_app.py b/api/tests/test_app.py index 59f6401..76cdaec 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -18,6 +18,7 @@ import pytest from fastapi import Request from fastapi.routing import APIRoute from loguru import logger +from pydantic import SecretStr from src.api.router import router from src.core.config import Settings @@ -66,6 +67,30 @@ async def test_healthcheck_subjects(client: httpx.AsyncClient, monkeypatch: pyte assert "jwt" not in str(body).lower() +@pytest.mark.parametrize("enabled", [False, True], ids=["when_nats_off", "when_nats_down"]) +async def test_healthcheck_returns_non_default_subjects_without_jwt( + client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, enabled: bool +): + """T3.1: отдаются именно настроенные (не умолчательные) subject'ы; непустой JWT в тело не попадает.""" + jwt = "JWTVALUEMARK" + nats = app_module.settings.OPENBAO.NATS + monkeypatch.setattr(app_module.settings, "NATS_ENABLED", enabled) + monkeypatch.setattr(nats, "NATS_TABULAR_REQUEST_SUBJECT", "env.files.tabular_uploaded") + monkeypatch.setattr(nats, "NATS_IMAGE_REQUEST_SUBJECT", "env.files.img_uploaded") + monkeypatch.setattr(nats, "NATS_RESULT_SUBJECT", "env.files.tabular_parsed") + monkeypatch.setattr(nats, "NATS_JWT", SecretStr(jwt)) + assert nats.NATS_JWT.get_secret_value() == jwt + + response = await client.get("/healthcheck") + + assert response.json()["subjects"] == { + "tabular_request": "env.files.tabular_uploaded", + "image_request": "env.files.img_uploaded", + "result": "env.files.tabular_parsed", + } + assert jwt not in response.text + + async def test_healthcheck_nats_down_when_enabled_without_connection( client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch ): @@ -239,6 +264,26 @@ async def test_lifespan_does_not_wait_for_nats_and_healthcheck_is_down(monkeypat assert not nats.is_connected +@pytest.mark.usefixtures("_no_smb") +async def test_lifespan_logs_nats_subjects_at_start(monkeypatch: pytest.MonkeyPatch): + """Tasks 1.2: при старте в лог пишутся используемые subject'ы NATS.""" + records: list[Any] = [] + handler_id = logger.add(lambda message: records.append(message.record), level="INFO") + monkeypatch.setattr(app_module, "_connect_s3", _unavailable_s3) + monkeypatch.setattr(app_module, "_create_nats", lambda: None) + monkeypatch.setattr(app_module.settings.OPENBAO.NATS, "NATS_RESULT_SUBJECT", "env.files.tabular_parsed") + try: + async with app_module.lifespan(app_module.app): + pass + finally: + logger.remove(handler_id) + + messages = [r["message"] for r in records if "Subject'ы NATS" in r["message"]] + assert len(messages) == 1 + assert "env.files.tabular_parsed" in messages[0] + assert "files.tabular_uploaded" in messages[0] + + def test_create_nats_disabled_returns_none(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(app_module.settings, "NATS_ENABLED", False) assert app_module._create_nats() is None diff --git a/api/tests/test_config.py b/api/tests/test_config.py index 36a30ce..aeba221 100644 --- a/api/tests/test_config.py +++ b/api/tests/test_config.py @@ -184,18 +184,44 @@ def test_nats_subjects_from_openbao(settings_from_env) -> None: assert nats.NATS_RESULT_SUBJECT == "test.files.tabular_parsed" +SUBJECT_KEYS = ("NATS_TABULAR_REQUEST_SUBJECT", "NATS_IMAGE_REQUEST_SUBJECT", "NATS_RESULT_SUBJECT") + + +@pytest.mark.parametrize("key", SUBJECT_KEYS) @pytest.mark.parametrize( "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) +def test_invalid_subject_rejected(settings_from_env, 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, first: str, second: str) -> None: + with pytest.raises(ValidationError, match=f"{first} и {second} совпадают"): + settings_from_env(**{f"OPENBAO__NATS__{first}": "files.same", f"OPENBAO__NATS__{second}": "files.same"}) + + +def test_invalid_subject_error_hides_nats_secrets(settings_from_env) -> None: + """Ошибка валидации subject'а при заданных JWT/seed не печатает их (маркеры короткие, см. тест ниже).""" + jwt_mark, seed_mark = "JWTMARK", "SEEDMARK" + with pytest.raises(ValidationError) as exc_info: + settings_from_env( + OPENBAO__NATS__NATS_JWT=jwt_mark, + OPENBAO__NATS__NATS_SEED=seed_mark, + OPENBAO__NATS__NATS_RESULT_SUBJECT="files.*", + ) + assert "NATS_RESULT_SUBJECT" in str(exc_info.value) + assert jwt_mark not in str(exc_info.value) + assert seed_mark not in str(exc_info.value) def test_nats_secrets_hidden_in_repr(settings_from_env) -> None: diff --git a/api/tests/test_nats_core.py b/api/tests/test_nats_core.py index 60b8e44..e1614a9 100644 --- a/api/tests/test_nats_core.py +++ b/api/tests/test_nats_core.py @@ -393,3 +393,18 @@ def test_connection_subscribes_to_configured_result_subject() -> None: nats = NATSSettings(NATS_URL="nats://x:4222", NATS_RESULT_SUBJECT="test.files.tabular_parsed") assert NatsConnection(nats).result_subject == "test.files.tabular_parsed" assert NatsConnection(nats, result_subject="override").result_subject == "override" + + +async def test_connection_subscribes_with_configured_result_subject() -> None: + """T3.2: subscribe вызывается с subject'ом из настроек (без override) и группой очереди.""" + nats = NATSSettings(NATS_URL="nats://x:4222", NATS_RESULT_SUBJECT="test.files.tabular_parsed") + world = _FakeWorld() + conn = NatsConnection(nats, client_factory=world, sleep=_RecordingSleep(), queue="g") + await conn.start() + assert await conn.wait_ready(timeout=2) + + await conn.subscribe_results(_handler) + + subscription = world.clients[-1].subscriptions[0] + assert (subscription["subject"], subscription["queue"]) == ("test.files.tabular_parsed", "g") + await conn.stop() diff --git a/openspec/changes/nats-env-config/tasks.md b/openspec/changes/nats-env-config/tasks.md index de04193..81fbfed 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`, `image_request`, `result`) при `nats = ok | down | off`; лог старта с subject'ами +- [x] 1.2 Unit: `/healthcheck.subjects` (`tabular_request`, `image_request`, `result`) при `nats = ok | down | off`; лог старта с subject'ами - [ ] 1.3 Интеграционный тест: приложение с 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` зелёный