From cd5409be6e8a9e8c338a3fbe0bb27274b055b349 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 12:56:27 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82=D1=8B=20NATS-=D0=BD?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA:=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B8=20=D0=B8=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D1=8B=20subject,=20=D1=83=D1=82=D0=B5=D1=87=D0=BA=D0=B0?= =?UTF-8?q?=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=D0=BE=D0=B2,=20strip=20?= =?UTF-8?q?seed,=20JWT=20=D0=B2=20/healthcheck,=20=D0=BB=D0=BE=D0=B3=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=B0=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фикс-волна minor-находок ревью (T6.1, T6.3, T2.2-аналог); tasks.md отмечен, кроме make check-full. Co-Authored-By: Claude Opus 5.5 --- api/tests/unit/test_app.py | 40 +++++++++++++++++++++++ api/tests/unit/test_config.py | 37 +++++++++++++++++---- api/tests/unit/test_nats.py | 17 ++++++++++ openspec/changes/nats-env-config/tasks.md | 16 ++++----- 4 files changed, 96 insertions(+), 14 deletions(-) diff --git a/api/tests/unit/test_app.py b/api/tests/unit/test_app.py index f30f7ab..7f436eb 100644 --- a/api/tests/unit/test_app.py +++ b/api/tests/unit/test_app.py @@ -75,6 +75,46 @@ async def test_healthcheck_subjects_from_env_without_jwt(local_env: Path, monkey assert "secret-jwt-value" not in response.text +async def test_healthcheck_nats_down_hides_jwt(local_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """NATS включён, но не подключён (`down`): значения JWT в теле `/healthcheck` нет.""" + monkeypatch.setenv("NATS_ENABLED", "true") + monkeypatch.setenv("OPENBAO__NATS__NATS_JWT", "secret-jwt-value") + never = asyncio.Event() + + async def fake_connect(self: NatsGateway) -> bool: + await never.wait() + return True + + async def fake_close(self: NatsGateway) -> None: + return None + + monkeypatch.setattr(NatsGateway, "connect", fake_connect) + monkeypatch.setattr(NatsGateway, "close", fake_close) + get_settings.cache_clear() + app = create_app(get_settings()) + + async with app.router.lifespan_context(app), _client(app) as client: + response = await client.get("/healthcheck") + + assert response.json()["nats"] == "down" + assert "secret-jwt-value" not in response.text + + +async def test_startup_logs_nats_subjects( + local_env: Path, monkeypatch: pytest.MonkeyPatch, log_lines: list[str] +) -> None: + monkeypatch.setenv("OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT", "test.files.img_uploaded") + monkeypatch.setenv("OPENBAO__NATS__NATS_RESULT_SUBJECT", "test.files.tabular_parsed") + get_settings.cache_clear() + app = create_app(get_settings()) + + async with app.router.lifespan_context(app): + pass + + line = next(line for line in log_lines if "Subject'ы NATS" in line) + assert "test.files.img_uploaded" in line and "test.files.tabular_parsed" in line + + async def test_start_in_local_mode(local_env: Path) -> None: """Spec «Старт в режиме локальных папок»: 200 с uptime, version, nats, hotfolder = ok; OPENBAO__SMB__* не нужны.""" app = create_app(get_settings()) diff --git a/api/tests/unit/test_config.py b/api/tests/unit/test_config.py index 3b84166..577ef7f 100644 --- a/api/tests/unit/test_config.py +++ b/api/tests/unit/test_config.py @@ -244,18 +244,43 @@ 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}": "test.same", f"OPENBAO__NATS__{second}": "test.same"}) + + +def test_invalid_subject_error_hides_nats_secrets(settings_from_env) -> None: + """Невалидный subject при заданных JWT и seed: секреты не попадают в текст ошибки валидации.""" + 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) + for secret in (JWT, SEED): + assert secret not in text + assert secret[:10] not in text + assert secret[-10:] not in text def test_nats_secrets_hidden_in_repr(settings_from_env) -> None: diff --git a/api/tests/unit/test_nats.py b/api/tests/unit/test_nats.py index 993075a..bd1aa2d 100644 --- a/api/tests/unit/test_nats.py +++ b/api/tests/unit/test_nats.py @@ -28,6 +28,7 @@ from src.core.nats import ( NatsGateway, NatsNotConnectedError, PublishFailed, + build_nats_auth, used_subjects, ) @@ -152,6 +153,22 @@ async def test_connect_with_nkeys_and_jwt_when_both_set(monkeypatch: pytest.Monk assert fake.jetstream_calls == 0 +def test_build_nats_auth_strips_whitespace_around_seed_and_jwt(monkeypatch: pytest.MonkeyPatch): + """Пробелы и перевод строки по краям (типично для значений из OpenBao) срезаются: seed читается, JWT чистый.""" + keypair = nkeys.from_seed(nkeys.encode_seed(b"\x02" * 32, nkeys.PREFIX_BYTE_USER)) + settings = make_settings( + monkeypatch, + OPENBAO__NATS__NATS_SEED=f" {keypair.seed.decode()}\n", + OPENBAO__NATS__NATS_JWT="\n dummy.jwt.token \n", + ) + + kwargs = build_nats_auth(settings.OPENBAO.NATS) + + assert kwargs["user_jwt_cb"]() == b"dummy.jwt.token" + signature = base64.b64decode(kwargs["signature_cb"]("nonce-1")) + assert keypair.verify(b"nonce-1", signature) is True + + @pytest.mark.parametrize( "env", [ diff --git a/openspec/changes/nats-env-config/tasks.md b/openspec/changes/nats-env-config/tasks.md index 743c337..ef689d0 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` (`image_request`, `result`) при `nats = ok | down | off`; лог старта с subject'ами -- [ ] 1.3 Тесты интеграционные (`tests/integration/`) — уникальные subject'ы через настройки +- [x] 1.2 Unit: `/healthcheck.subjects` (`image_request`, `result`) при `nats = ok | down | off`; лог старта с subject'ами +- [x] 1.3 Тесты интеграционные (`tests/integration/`) — уникальные 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` зелёный