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` — `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:
|
if not self.is_connected:
|
||||||
raise NatsNotConnectedError(f"Нет соединения с NATS: подписка на {subject} невозможна")
|
raise NatsNotConnectedError(f"Нет соединения с NATS: подписка на {subject} невозможна")
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ from typing import Any
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from pydantic import SecretStr
|
||||||
|
|
||||||
from src.contract import NATS_MSG_ID_HEADER, RESULT_SUBJECT
|
from src.contract import NATS_MSG_ID_HEADER, RESULT_SUBJECT
|
||||||
from src.core.clients import S3Storage
|
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
|
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) ------------------------------------------------------------------------
|
# --- проверка бакета при старте (_connect_s3) ------------------------------------------------------------------------
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def log_records() -> Iterator[list[Any]]:
|
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")
|
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
|
nats = settings_from_env().OPENBAO.NATS
|
||||||
assert nats.NATS_TABULAR_REQUEST_SUBJECT == "files.tabular_uploaded"
|
assert nats.NATS_TABULAR_REQUEST_SUBJECT == "files.tabular_uploaded"
|
||||||
assert nats.NATS_IMAGE_REQUEST_SUBJECT == "files.img_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"}
|
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(
|
nats = settings_from_env(
|
||||||
OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT="test.files.tabular_uploaded",
|
OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT="test.files.tabular_uploaded",
|
||||||
OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT="test.files.img_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",
|
"value",
|
||||||
["", "files.*", "files.>", ">", "files..tabular", ".files", "files.", "files tabular", "files.tabular_uploaded "],
|
["", "files.*", "files.>", ">", "files..tabular", ".files", "files.", "files tabular", "files.tabular_uploaded "],
|
||||||
)
|
)
|
||||||
def test_invalid_subject_rejected(settings_from_env, value: str) -> None:
|
@pytest.mark.parametrize("key", ["NATS_TABULAR_REQUEST_SUBJECT", "NATS_IMAGE_REQUEST_SUBJECT", "NATS_RESULT_SUBJECT"])
|
||||||
with pytest.raises(ValidationError, match="NATS_RESULT_SUBJECT"):
|
def test_invalid_subject_rejected(settings_from_env: SettingsFromEnvFactory, key: str, value: str) -> None:
|
||||||
settings_from_env(OPENBAO__NATS__NATS_RESULT_SUBJECT=value)
|
with pytest.raises(ValidationError, match=key):
|
||||||
|
settings_from_env(**{f"OPENBAO__NATS__{key}": value})
|
||||||
|
|
||||||
|
|
||||||
def test_equal_subjects_rejected(settings_from_env) -> None:
|
@pytest.mark.parametrize(
|
||||||
with pytest.raises(ValidationError, match="NATS_TABULAR_REQUEST_SUBJECT и NATS_RESULT_SUBJECT совпадают"):
|
("first", "second"),
|
||||||
settings_from_env(OPENBAO__NATS__NATS_RESULT_SUBJECT="files.tabular_uploaded")
|
[
|
||||||
|
("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)
|
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 settings.OPENBAO.NATS.NATS_JWT.get_secret_value() == JWT
|
||||||
assert JWT not in repr(settings) and SEED not in repr(settings)
|
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
|
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).
|
"""Ошибка «нет поля» печатает вход всей группы — JWT и seed туда попадать не должны (Review Focus 1).
|
||||||
|
|
||||||
pydantic в тексте ошибки усекает значения (`'SUACSSL3UA...PQYYYCU3VY'`), поэтому полный секрет там не появляется
|
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 secret in (JWT, SEED):
|
||||||
for fragment in (secret[:10], secret[-10:]):
|
for fragment in (secret[:10], secret[-10:]):
|
||||||
assert fragment not in text
|
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.1 Unit: умолчания subject'ов, чтение `OPENBAO__NATS__NATS_*_SUBJECT`, отказ на `*`/`>`/пробеле/пустом токене и
|
- [x] 1.1 Unit: умолчания subject'ов, чтение `OPENBAO__NATS__NATS_*_SUBJECT`, отказ на `*`/`>`/пробеле/пустом токене и
|
||||||
совпадающих именах; `SecretStr` для `NATS_JWT`/`NATS_SEED`, отсутствие значений в ошибке валидации и `repr`
|
совпадающих именах; `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'ы через настройки
|
- [ ] 1.3 Тесты roundtrip (`tests/integration/test_roundtrip.py`) — уникальные subject'ы через настройки
|
||||||
|
|
||||||
## 2. Реализация
|
## 2. Реализация
|
||||||
|
|
||||||
- [ ] 2.1 `core/config.py`: поля и валидация `NATSSettings`, `SecretStr`, `hide_input_in_errors=True`
|
- [x] 2.1 `core/config.py`: поля и валидация `NATSSettings`, `SecretStr`, `hide_input_in_errors=True`
|
||||||
- [ ] 2.2 Колбэки nkeys — `get_secret_value()`
|
- [x] 2.2 Колбэки nkeys — `get_secret_value()`
|
||||||
- [ ] 2.3 Подписка и публикация по настройкам; константы `contract/subjects.py` — умолчания
|
- [x] 2.3 Подписка и публикация по настройкам; константы `contract/subjects.py` — умолчания
|
||||||
- [ ] 2.4 `/healthcheck.subjects`, лог при старте
|
- [x] 2.4 `/healthcheck.subjects`, лог при старте
|
||||||
|
|
||||||
## 3. Документы
|
## 3. Документы
|
||||||
|
|
||||||
- [ ] 3.1 `api/.env.example`, README (переменные NATS, `/healthcheck`)
|
- [x] 3.1 `api/.env.example`, README (переменные NATS, `/healthcheck`)
|
||||||
- [ ] 3.2 `make check-full` зелёный
|
- [ ] 3.2 `make check-full` зелёный
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user