Fix-wave ревью nats-env-config: тесты subject'ов, JWT в healthcheck, лог старта, subject is not None

T4.1, T2.1, T2.2, T5.1, T5.2, T5.3 (тесты); T5.6 (subscribe_requests: пустая строка не подменяется умолчанием).
Отмечены пункты 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:56:10 +04:00
parent 1d059993f0
commit 4f0636f627
4 changed files with 110 additions and 18 deletions

View File

@ -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} невозможна")

View File

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

View File

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

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