"""Unit-тесты настроек `src/core/config.py` (spec «Старт с минимальным окружением»; R-35).""" from collections.abc import Callable import pytest from pydantic import ValidationError from src.core.config import Settings SettingsFactory = Callable[..., Settings] SettingsFromEnvFactory = Callable[..., Settings] # Минимальный набор обязательных переменных (как MINIMAL_ENV в conftest): окружение процесса, без .env MINIMAL_ENV_VARS = { "DEBUG": "true", "OPENBAO__MINIO__MINIO_URL": "localhost:9000", "OPENBAO__MINIO__MINIO_ADMIN_LOGIN": "minioadmin", "OPENBAO__MINIO__MINIO_ADMIN_PASSWORD": "minioadmin", "OPENBAO__MINIO__MINIO_BUCKET": "arm-ka", "OPENBAO__NATS__NATS_URL": "nats://localhost:4222", } @pytest.fixture def settings_from_env(minimal_settings: SettingsFactory, monkeypatch: pytest.MonkeyPatch) -> SettingsFromEnvFactory: """Фабрика `settings_from_env(**env)`: Settings из переменных окружения поверх минимального набора. Окружение процесса очищено фикстурой `minimal_settings` (она же убирает `OPENBAO__*`); значение `None` — удалить переменную. `.env` не читается (`_env_file=None`). """ def build(**env: str | None) -> Settings: for name, value in MINIMAL_ENV_VARS.items(): monkeypatch.setenv(name, value) for name, value in env.items(): if value is None: monkeypatch.delenv(name, raising=False) else: monkeypatch.setenv(name, value) return Settings(_env_file=None) # type: ignore[call-arg] return build JWT = "eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.secret-payload.sig" SEED = "SUACSSL3UAHUDXKFSNVUZRF5UHPMWZ6BFDTJ7M6USDXIEDNPPQYYYCU3VY" def test_minimal_environment_gives_defaults(minimal_settings: SettingsFactory) -> None: settings = minimal_settings() assert settings.DEBUG is True assert settings.OPENBAO.MINIO.MINIO_BUCKET == "arm-ka" assert settings.OPENBAO.NATS.NATS_URL == "nats://localhost:4222" assert settings.OPENBAO.NATS.NATS_SEED.get_secret_value() == "" assert settings.OPENBAO.NATS.NATS_JWT.get_secret_value() == "" assert settings.NATS_ENABLED is True assert settings.SERVICE_NAME == "tabular-parser" assert settings.WORKER_CONCURRENCY == 1 assert settings.PUBLISH_RETRIES == 3 # noqa: PLR2004 - значение по умолчанию из spec assert settings.DONE_CACHE_SIZE == 1000 # noqa: PLR2004 - значение по умолчанию из spec assert settings.PARSE_MAX_BYTES == 50 * 1024 * 1024 # design D-8: 50 МиБ assert settings.PARSE_TIMEOUT_SECONDS == 120 # noqa: PLR2004 - значение по умолчанию из design D-8 def test_parse_limits_must_be_positive(minimal_settings: SettingsFactory) -> None: with pytest.raises(ValidationError, match="PARSE_MAX_BYTES"): minimal_settings("PARSE_MAX_BYTES=0\n") with pytest.raises(ValidationError, match="PARSE_TIMEOUT_SECONDS"): minimal_settings("PARSE_TIMEOUT_SECONDS=0\n") def test_publish_retries_zero_is_allowed(minimal_settings: SettingsFactory) -> None: """0 — без повторов: одна попытка публикации (NatsConnection принимает publish_retries ≥ 0).""" assert minimal_settings("PUBLISH_RETRIES=0\n").PUBLISH_RETRIES == 0 def test_publish_retries_negative_is_rejected(minimal_settings: SettingsFactory) -> None: with pytest.raises(ValidationError, match="PUBLISH_RETRIES"): minimal_settings("PUBLISH_RETRIES=-1\n") def test_service_name_other_than_contract_value_is_rejected(minimal_settings: SettingsFactory) -> None: """SERVICE_NAME — `service.name` конвертов контракта: другое значение — ошибка конфигурации при старте.""" with pytest.raises(ValidationError, match="SERVICE_NAME"): minimal_settings("SERVICE_NAME=scan-gateway\n") 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" assert nats.NATS_RESULT_SUBJECT == "files.tabular_parsed" assert nats.request_subjects() == {"tabular": "files.tabular_uploaded", "image": "files.img_uploaded"} 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", OPENBAO__NATS__NATS_RESULT_SUBJECT="test.files.tabular_parsed", ).OPENBAO.NATS assert nats.request_subjects() == {"tabular": "test.files.tabular_uploaded", "image": "test.files.img_uploaded"} assert nats.NATS_RESULT_SUBJECT == "test.files.tabular_parsed" @pytest.mark.parametrize( "value", ["", "files.*", "files.>", ">", "files..tabular", ".files", "files.", "files tabular", "files.tabular_uploaded "], ) @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}) @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: 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) assert JWT not in str(settings) and SEED not in str(settings) MINIO_KEYS = ("MINIO_URL", "MINIO_ADMIN_LOGIN", "MINIO_ADMIN_PASSWORD", "MINIO_BUCKET", "MINIO_SECURE") @pytest.mark.parametrize( "missing", [ ("OPENBAO__NATS__NATS_URL",), # нет поля в группе NATS: в ошибке — вход группы NATS (с JWT) tuple(f"OPENBAO__MINIO__{key}" for key in MINIO_KEYS), # нет группы MINIO: в ошибке — вход всего OPENBAO ], ) def test_validation_error_hides_nats_secrets( settings_from_env: SettingsFromEnvFactory, missing: tuple[str, ...] ) -> None: """Ошибка «нет поля» печатает вход всей группы — JWT и seed туда попадать не должны (Review Focus 1). pydantic в тексте ошибки усекает значения (`'SUACSSL3UA...PQYYYCU3VY'`), поэтому полный секрет там не появляется даже без скрытия; проверяем края секретов — они и попадают в текст при утечке. """ with pytest.raises(ValidationError) as exc_info: settings_from_env(OPENBAO__NATS__NATS_JWT=JWT, OPENBAO__NATS__NATS_SEED=SEED, **dict.fromkeys(missing)) text = str(exc_info.value) 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