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:
parent
1d059993f0
commit
4f0636f627
@ -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} невозможна")
|
||||
|
||||
|
||||
@ -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]]:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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` зелёный
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user