Тесты настроек NATS: fix-wave по minor-находкам ревью (T2.1, T2.2, T3.1, T3.2, лог старта)

- invalid_subject параметризован по трём ключам, equality покрывает три пары
- кейс утечки JWT/seed при невалидном subject
- /healthcheck с нестандартными subject'ами и проверкой отсутствия JWT
- subscribe с настроенным subject'ом, лог старта с subject'ами
- tasks.md: отмечены 1.1, 1.2, 2.x, 3.1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-13 12:57:02 +04:00
parent 9d317b6260
commit db8b0bcc13
4 changed files with 99 additions and 13 deletions

View File

@ -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

View File

@ -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:

View File

@ -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()

View File

@ -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` зелёный