NATS: subject'ы и JWT из OPENBAO__NATS__*, SecretStr, скрытие входа в ошибках (#106)
- NATSSettings: NATS_TABULAR_REQUEST_SUBJECT / NATS_IMAGE_REQUEST_SUBJECT / NATS_RESULT_SUBJECT с умолчанием имён контракта v1.0; проверка формата (без пробелов, '*', '>', пустых токенов) и попарного различия. - request_subjects() и request_subject(pipeline, subjects) — карта subject'ов окружения. - NATS_SEED / NATS_JWT — SecretStr; build_nats_auth читает значения через get_secret_value(). - hide_input_in_errors=True во всех классах настроек: ошибка «нет поля» не печатает JWT/seed. - Схемы контракта api/contracts/ — копии docs/contracts/ (побайтно). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4b6655238b
commit
f0c1073918
@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "arm-ka/recognition-request/v1",
|
"$id": "arm-ka/recognition-request/v1",
|
||||||
"title": "Запрос распознавания файла (arm-core → tabular-parser / scan-gateway), контракт v1.0",
|
"title": "Запрос распознавания файла (arm-core → tabular-parser / scan-gateway), контракт v1.0",
|
||||||
"description": "Тело сообщения на subject files.tabular_uploaded или files.img_uploaded; subject должен соответствовать полю pipeline (tabular → files.tabular_uploaded, image → files.img_uploaded). Заголовок Nats-Msg-Id = <job_id>:<attempt> (не поле тела). Один файл (stored_file) → один job.",
|
"description": "Тело сообщения на subject запроса; subject — настройка окружения и должен соответствовать полю pipeline (tabular → NATS_TABULAR_REQUEST_SUBJECT, по умолчанию files.tabular_uploaded; image → NATS_IMAGE_REQUEST_SUBJECT, по умолчанию files.img_uploaded). Заголовок Nats-Msg-Id = <job_id>:<attempt> (не поле тела). Один файл (stored_file) → один job.",
|
||||||
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.",
|
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["schema_version", "job_id", "file_id", "pipeline", "file_type", "original_name", "s3", "reply_deadline", "attempt"],
|
"required": ["schema_version", "job_id", "file_id", "pipeline", "file_type", "original_name", "s3", "reply_deadline", "attempt"],
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "arm-ka/recognition-result-message/v1",
|
"$id": "arm-ka/recognition-result-message/v1",
|
||||||
"title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0",
|
"title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0",
|
||||||
"description": "Тело сообщения на subject files.tabular_parsed. Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:<job_id>:<attempt> — для status = processing; result:<job_id>:<attempt> — для done, needs_check и error. Форма запроса <job_id>:<attempt> описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).",
|
"description": "Тело сообщения на subject ответа (настройка окружения NATS_RESULT_SUBJECT, по умолчанию files.tabular_parsed). Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:<job_id>:<attempt> — для status = processing; result:<job_id>:<attempt> — для done, needs_check и error. Форма запроса <job_id>:<attempt> описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).",
|
||||||
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.",
|
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["schema_version", "job_id", "file_id", "status", "service"],
|
"required": ["schema_version", "job_id", "file_id", "status", "service"],
|
||||||
|
|||||||
@ -22,10 +22,12 @@ REQUEST_SUBJECTS: Mapping[str, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def request_subject(pipeline: str) -> str:
|
def request_subject(pipeline: str, subjects: Mapping[str, str] = REQUEST_SUBJECTS) -> str:
|
||||||
"""Subject запроса по pipeline: `tabular` → `files.tabular_uploaded`, `image` → `files.img_uploaded`."""
|
"""Subject запроса по pipeline. `subjects` — карта `pipeline → subject` окружения
|
||||||
|
(`NATSSettings.request_subjects()`); по умолчанию — имена контракта: `tabular` → `files.tabular_uploaded`,
|
||||||
|
`image` → `files.img_uploaded`."""
|
||||||
try:
|
try:
|
||||||
return REQUEST_SUBJECTS[pipeline]
|
return subjects[pipeline]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise ValueError(f"неизвестный pipeline {pipeline!r}: допустимы {', '.join(get_args(Pipeline))}") from None
|
raise ValueError(f"неизвестный pipeline {pipeline!r}: допустимы {', '.join(get_args(Pipeline))}") from None
|
||||||
|
|
||||||
|
|||||||
@ -3,19 +3,27 @@ import os
|
|||||||
import re
|
import re
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from pydantic import NonNegativeInt, PositiveInt, field_validator, model_validator
|
from pydantic import NonNegativeInt, PositiveInt, SecretStr, ValidationInfo, field_validator, model_validator
|
||||||
from pydantic_settings import BaseSettings, NoDecode, PydanticBaseSettingsSource, SettingsConfigDict
|
from pydantic_settings import BaseSettings, NoDecode, PydanticBaseSettingsSource, SettingsConfigDict
|
||||||
|
|
||||||
|
from src.contract.subjects import IMAGE_REQUEST_SUBJECT, RESULT_SUBJECT, TABULAR_REQUEST_SUBJECT
|
||||||
from src.core.settings_sources import OpenBaoAppSettingsSource
|
from src.core.settings_sources import OpenBaoAppSettingsSource
|
||||||
|
|
||||||
DB_SCHEMA_RE = re.compile(r"[a-z_][a-z0-9_]{0,62}")
|
DB_SCHEMA_RE = re.compile(r"[a-z_][a-z0-9_]{0,62}")
|
||||||
|
# Subject NATS без шаблонов: непустые токены через точку, без пробельных символов, `*` и `>` (docs nats-env-config)
|
||||||
|
NATS_SUBJECT_RE = re.compile(r"[^\s.*>]+(?:\.[^\s.*>]+)*")
|
||||||
|
NATS_SUBJECT_FIELDS = ("NATS_TABULAR_REQUEST_SUBJECT", "NATS_IMAGE_REQUEST_SUBJECT", "NATS_RESULT_SUBJECT")
|
||||||
|
|
||||||
|
|
||||||
class DBSettings(BaseSettings):
|
class DBSettings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(hide_input_in_errors=True)
|
||||||
|
|
||||||
DATABASE_URL: str
|
DATABASE_URL: str
|
||||||
|
|
||||||
|
|
||||||
class MinioSettings(BaseSettings):
|
class MinioSettings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(hide_input_in_errors=True)
|
||||||
|
|
||||||
MINIO_URL: str
|
MINIO_URL: str
|
||||||
MINIO_ADMIN_LOGIN: str
|
MINIO_ADMIN_LOGIN: str
|
||||||
MINIO_ADMIN_PASSWORD: str
|
MINIO_ADMIN_PASSWORD: str
|
||||||
@ -24,15 +32,57 @@ class MinioSettings(BaseSettings):
|
|||||||
|
|
||||||
|
|
||||||
class NATSSettings(BaseSettings):
|
class NATSSettings(BaseSettings):
|
||||||
|
"""Подключение и subject'ы NATS — секреты окружения `OPENBAO__NATS__*` (прод и тест — свои наборы).
|
||||||
|
|
||||||
|
Пустые `NATS_SEED` и `NATS_JWT` — подключение без nkeys (локальная разработка, тесты). Subject'ы по умолчанию —
|
||||||
|
имена контракта v1.0; у ядра и сервисов одного окружения они обязаны совпадать (docs, change nats-env-config).
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(hide_input_in_errors=True)
|
||||||
|
|
||||||
NATS_URL: str
|
NATS_URL: str
|
||||||
NATS_SEED: str = "" # пусто — подключение к NATS без nkeys (локальная разработка, тесты)
|
NATS_SEED: SecretStr = SecretStr("") # пусто — подключение к NATS без nkeys (локальная разработка, тесты)
|
||||||
NATS_JWT: str = ""
|
NATS_JWT: SecretStr = SecretStr("")
|
||||||
|
NATS_TABULAR_REQUEST_SUBJECT: str = TABULAR_REQUEST_SUBJECT
|
||||||
|
NATS_IMAGE_REQUEST_SUBJECT: str = IMAGE_REQUEST_SUBJECT
|
||||||
|
NATS_RESULT_SUBJECT: str = RESULT_SUBJECT
|
||||||
|
|
||||||
|
@field_validator(*NATS_SUBJECT_FIELDS)
|
||||||
|
@classmethod
|
||||||
|
def _check_subject(cls, value: str, info: ValidationInfo) -> str:
|
||||||
|
"""Конкретный subject без шаблонов: иначе подписка молча слушала бы чужой трафик или ничего."""
|
||||||
|
if not NATS_SUBJECT_RE.fullmatch(value):
|
||||||
|
raise ValueError(
|
||||||
|
f"{info.field_name}={value!r}: нужен subject NATS из непустых токенов через точку, "
|
||||||
|
"без пробелов, '*' и '>'"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_distinct_subjects(self) -> "NATSSettings":
|
||||||
|
"""Три subject'а попарно различны: совпадение запроса и ответа — сервис получает свои ответы, двух запросов —
|
||||||
|
каждый запрос получают оба сервиса."""
|
||||||
|
seen: dict[str, str] = {}
|
||||||
|
for field in NATS_SUBJECT_FIELDS:
|
||||||
|
subject = getattr(self, field)
|
||||||
|
if subject in seen:
|
||||||
|
raise ValueError(
|
||||||
|
f"{seen[subject]} и {field} совпадают ({subject!r}): subject'ы NATS должны различаться"
|
||||||
|
)
|
||||||
|
seen[subject] = field
|
||||||
|
return self
|
||||||
|
|
||||||
|
def request_subjects(self) -> dict[str, str]:
|
||||||
|
"""Subject запроса по `pipeline` (`tabular`, `image`) — аргумент `request_subject` контракта."""
|
||||||
|
return {"tabular": self.NATS_TABULAR_REQUEST_SUBJECT, "image": self.NATS_IMAGE_REQUEST_SUBJECT}
|
||||||
|
|
||||||
|
|
||||||
class SMBSettings(BaseSettings):
|
class SMBSettings(BaseSettings):
|
||||||
"""SMB (горячие папки ИС Сканирование, шаблон). Необязательны: ядру в Срезе 0 SMB не нужен; пустой `SMB_HOST` —
|
"""SMB (горячие папки ИС Сканирование, шаблон). Необязательны: ядру в Срезе 0 SMB не нужен; пустой `SMB_HOST` —
|
||||||
сессия SMB не регистрируется."""
|
сессия SMB не регистрируется."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(hide_input_in_errors=True)
|
||||||
|
|
||||||
SMB_HOST: str = ""
|
SMB_HOST: str = ""
|
||||||
SMB_PATH: str = ""
|
SMB_PATH: str = ""
|
||||||
SMB_LOGIN: str = ""
|
SMB_LOGIN: str = ""
|
||||||
@ -40,6 +90,8 @@ class SMBSettings(BaseSettings):
|
|||||||
|
|
||||||
|
|
||||||
class OpenBAOSettings(BaseSettings):
|
class OpenBAOSettings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(hide_input_in_errors=True)
|
||||||
|
|
||||||
DATABASE: DBSettings
|
DATABASE: DBSettings
|
||||||
MINIO: MinioSettings
|
MINIO: MinioSettings
|
||||||
NATS: NATSSettings
|
NATS: NATSSettings
|
||||||
@ -57,6 +109,8 @@ class OpenBAOSettings(BaseSettings):
|
|||||||
|
|
||||||
|
|
||||||
class ServerSettings(BaseSettings):
|
class ServerSettings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(hide_input_in_errors=True)
|
||||||
|
|
||||||
HOST: str = "0.0.0.0"
|
HOST: str = "0.0.0.0"
|
||||||
PORT: int = 8000
|
PORT: int = 8000
|
||||||
|
|
||||||
@ -162,6 +216,7 @@ class Settings(BaseSettings):
|
|||||||
env_file=os.environ.get("ENV_FILE", ".env"),
|
env_file=os.environ.get("ENV_FILE", ".env"),
|
||||||
env_nested_delimiter="__",
|
env_nested_delimiter="__",
|
||||||
nested_model_default_partial_update=False,
|
nested_model_default_partial_update=False,
|
||||||
|
hide_input_in_errors=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -66,8 +66,8 @@ def build_nats_auth(settings: NATSSettings) -> dict[str, Any]:
|
|||||||
Пустые `NATS_SEED` и `NATS_JWT` — подключение без учётных данных (docker compose, тесты): `{}`. Заданы оба —
|
Пустые `NATS_SEED` и `NATS_JWT` — подключение без учётных данных (docker compose, тесты): `{}`. Заданы оба —
|
||||||
колбэки nkeys/JWT, как в шаблоне. Задан только один или seed не читается — `NatsConfigError`.
|
колбэки nkeys/JWT, как в шаблоне. Задан только один или seed не читается — `NatsConfigError`.
|
||||||
"""
|
"""
|
||||||
seed = settings.NATS_SEED
|
seed = settings.NATS_SEED.get_secret_value()
|
||||||
jwt = settings.NATS_JWT
|
jwt = settings.NATS_JWT.get_secret_value()
|
||||||
if not seed and not jwt:
|
if not seed and not jwt:
|
||||||
return {}
|
return {}
|
||||||
if not seed or not jwt:
|
if not seed or not jwt:
|
||||||
|
|||||||
@ -36,15 +36,7 @@ def test_env_test_is_read(clean_env: pytest.MonkeyPatch) -> None:
|
|||||||
|
|
||||||
def test_defaults(clean_env: pytest.MonkeyPatch) -> None:
|
def test_defaults(clean_env: pytest.MonkeyPatch) -> None:
|
||||||
"""Значения по умолчанию классов настроек: env-файла нет, заданы только обязательные OPENBAO__* (SMB не нужен)."""
|
"""Значения по умолчанию классов настроек: env-файла нет, заданы только обязательные OPENBAO__* (SMB не нужен)."""
|
||||||
required = {
|
for key, value in REQUIRED_ENV.items():
|
||||||
"OPENBAO__DATABASE__DATABASE_URL": "postgresql+asyncpg://u:p@localhost:5432/d",
|
|
||||||
"OPENBAO__MINIO__MINIO_URL": "localhost:9000",
|
|
||||||
"OPENBAO__MINIO__MINIO_ADMIN_LOGIN": "login",
|
|
||||||
"OPENBAO__MINIO__MINIO_ADMIN_PASSWORD": "password",
|
|
||||||
"OPENBAO__MINIO__MINIO_BUCKET": "bucket",
|
|
||||||
"OPENBAO__NATS__NATS_URL": "nats://localhost:4222",
|
|
||||||
}
|
|
||||||
for key, value in required.items():
|
|
||||||
clean_env.setenv(key, value)
|
clean_env.setenv(key, value)
|
||||||
s = Settings(_env_file=None) # type: ignore[call-arg]
|
s = Settings(_env_file=None) # type: ignore[call-arg]
|
||||||
assert s.DEBUG is False
|
assert s.DEBUG is False
|
||||||
@ -58,8 +50,8 @@ def test_defaults(clean_env: pytest.MonkeyPatch) -> None:
|
|||||||
assert s.NATS_ENABLED is False
|
assert s.NATS_ENABLED is False
|
||||||
assert (s.OUTBOX_POLL_SECONDS, s.ACK_TIMEOUT_SECONDS, s.OUTBOX_MAX_REPUBLISH) == (5, 30, 3)
|
assert (s.OUTBOX_POLL_SECONDS, s.ACK_TIMEOUT_SECONDS, s.OUTBOX_MAX_REPUBLISH) == (5, 30, 3)
|
||||||
assert (s.UPLOAD_MAX_BYTES, s.MAPPING_POLL_SECONDS, s.MAPPING_MAX_ATTEMPTS) == (50 * 1024 * 1024, 5, 3)
|
assert (s.UPLOAD_MAX_BYTES, s.MAPPING_POLL_SECONDS, s.MAPPING_MAX_ATTEMPTS) == (50 * 1024 * 1024, 5, 3)
|
||||||
assert s.OPENBAO.NATS.NATS_SEED == ""
|
assert s.OPENBAO.NATS.NATS_SEED.get_secret_value() == ""
|
||||||
assert s.OPENBAO.NATS.NATS_JWT == ""
|
assert s.OPENBAO.NATS.NATS_JWT.get_secret_value() == ""
|
||||||
assert s.OPENBAO.MINIO.MINIO_SECURE is True
|
assert s.OPENBAO.MINIO.MINIO_SECURE is True
|
||||||
assert (s.server.HOST, s.server.PORT) == ("0.0.0.0", 8000) # noqa: S104 - по умолчанию, как в контейнере
|
assert (s.server.HOST, s.server.PORT) == ("0.0.0.0", 8000) # noqa: S104 - по умолчанию, как в контейнере
|
||||||
smb = s.OPENBAO.SMB
|
smb = s.OPENBAO.SMB
|
||||||
@ -69,8 +61,8 @@ def test_defaults(clean_env: pytest.MonkeyPatch) -> None:
|
|||||||
def test_empty_nats_seed_and_jwt_allowed(clean_env: pytest.MonkeyPatch) -> None:
|
def test_empty_nats_seed_and_jwt_allowed(clean_env: pytest.MonkeyPatch) -> None:
|
||||||
"""Пустые NATS_SEED/NATS_JWT допустимы (подключение без nkeys)."""
|
"""Пустые NATS_SEED/NATS_JWT допустимы (подключение без nkeys)."""
|
||||||
s = Settings(_env_file=API_DIR / ".env.example") # type: ignore[call-arg]
|
s = Settings(_env_file=API_DIR / ".env.example") # type: ignore[call-arg]
|
||||||
assert s.OPENBAO.NATS.NATS_SEED == ""
|
assert s.OPENBAO.NATS.NATS_SEED.get_secret_value() == ""
|
||||||
assert s.OPENBAO.NATS.NATS_JWT == ""
|
assert s.OPENBAO.NATS.NATS_JWT.get_secret_value() == ""
|
||||||
assert s.NATS_ENABLED is True
|
assert s.NATS_ENABLED is True
|
||||||
assert s.OPENBAO.SMB.SMB_HOST == ""
|
assert s.OPENBAO.SMB.SMB_HOST == ""
|
||||||
assert (s.server.HOST, s.server.PORT) == ("127.0.0.1", 8000) # dev-запуск — только loopback (M-11)
|
assert (s.server.HOST, s.server.PORT) == ("127.0.0.1", 8000) # dev-запуск — только loopback (M-11)
|
||||||
@ -155,3 +147,84 @@ def test_cors_origins_from_env(clean_env: pytest.MonkeyPatch, raw: str, expected
|
|||||||
clean_env.setenv("CORS_ORIGINS", raw)
|
clean_env.setenv("CORS_ORIGINS", raw)
|
||||||
s = Settings(_env_file=API_DIR / ".env.test") # type: ignore[call-arg]
|
s = Settings(_env_file=API_DIR / ".env.test") # type: ignore[call-arg]
|
||||||
assert s.CORS_ORIGINS == expected
|
assert s.CORS_ORIGINS == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def settings_from_env(clean_env: pytest.MonkeyPatch):
|
||||||
|
def make(**env: str | None) -> Settings:
|
||||||
|
for key, value in {**REQUIRED_ENV, **env}.items():
|
||||||
|
if value is None:
|
||||||
|
clean_env.delenv(key, raising=False)
|
||||||
|
else:
|
||||||
|
clean_env.setenv(key, value)
|
||||||
|
return Settings(_env_file=None) # type: ignore[call-arg]
|
||||||
|
|
||||||
|
return make
|
||||||
|
|
||||||
|
|
||||||
|
JWT = "eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.secret-payload.sig"
|
||||||
|
SEED = "SUACSSL3UAHUDXKFSNVUZRF5UHPMWZ6BFDTJ7M6USDXIEDNPPQYYYCU3VY"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nats_subjects_default_to_contract_names(settings_from_env) -> 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) -> 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 "],
|
||||||
|
)
|
||||||
|
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_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")
|
||||||
|
|
||||||
|
|
||||||
|
def test_nats_secrets_hidden_in_repr(settings_from_env) -> 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, missing: tuple[str, ...]) -> None:
|
||||||
|
"""Ошибка «нет поля» печатает вход всей группы — JWT и seed туда попадать не должны (Review Focus 1).
|
||||||
|
|
||||||
|
Маркеры короткие: pydantic обрезает `input_value` в тексте ошибки (около 50 символов по середине), и полный
|
||||||
|
JWT там не виден даже без скрытия — проверка на полном значении прошла бы вхолостую.
|
||||||
|
"""
|
||||||
|
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, **dict.fromkeys(missing)
|
||||||
|
)
|
||||||
|
assert jwt_mark not in str(exc_info.value)
|
||||||
|
assert seed_mark not in str(exc_info.value)
|
||||||
|
|||||||
@ -626,6 +626,12 @@ def test_request_subject_by_pipeline() -> None:
|
|||||||
assert NATS_MSG_ID_HEADER == "Nats-Msg-Id"
|
assert NATS_MSG_ID_HEADER == "Nats-Msg-Id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_subject_uses_environment_map() -> None:
|
||||||
|
subjects = {"tabular": "test.files.tabular_uploaded", "image": "test.files.img_uploaded"}
|
||||||
|
assert request_subject("tabular", subjects) == "test.files.tabular_uploaded"
|
||||||
|
assert request_subject("image", subjects) == "test.files.img_uploaded"
|
||||||
|
|
||||||
|
|
||||||
def test_request_subject_rejects_unknown_pipeline() -> None:
|
def test_request_subject_rejects_unknown_pipeline() -> None:
|
||||||
with pytest.raises(ValueError, match="pipeline"):
|
with pytest.raises(ValueError, match="pipeline"):
|
||||||
request_subject("ocr")
|
request_subject("ocr")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user