Тесты NATS-настроек: все ключи и пары subject, утечка секретов, strip seed, JWT в /healthcheck, лог старта (#32)
Фикс-волна minor-находок ревью (T6.1, T6.3, T2.2-аналог); tasks.md отмечен, кроме make check-full. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
192500e9f9
commit
cd5409be6e
@ -75,6 +75,46 @@ async def test_healthcheck_subjects_from_env_without_jwt(local_env: Path, monkey
|
||||
assert "secret-jwt-value" not in response.text
|
||||
|
||||
|
||||
async def test_healthcheck_nats_down_hides_jwt(local_env: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""NATS включён, но не подключён (`down`): значения JWT в теле `/healthcheck` нет."""
|
||||
monkeypatch.setenv("NATS_ENABLED", "true")
|
||||
monkeypatch.setenv("OPENBAO__NATS__NATS_JWT", "secret-jwt-value")
|
||||
never = asyncio.Event()
|
||||
|
||||
async def fake_connect(self: NatsGateway) -> bool:
|
||||
await never.wait()
|
||||
return True
|
||||
|
||||
async def fake_close(self: NatsGateway) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(NatsGateway, "connect", fake_connect)
|
||||
monkeypatch.setattr(NatsGateway, "close", fake_close)
|
||||
get_settings.cache_clear()
|
||||
app = create_app(get_settings())
|
||||
|
||||
async with app.router.lifespan_context(app), _client(app) as client:
|
||||
response = await client.get("/healthcheck")
|
||||
|
||||
assert response.json()["nats"] == "down"
|
||||
assert "secret-jwt-value" not in response.text
|
||||
|
||||
|
||||
async def test_startup_logs_nats_subjects(
|
||||
local_env: Path, monkeypatch: pytest.MonkeyPatch, log_lines: list[str]
|
||||
) -> None:
|
||||
monkeypatch.setenv("OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT", "test.files.img_uploaded")
|
||||
monkeypatch.setenv("OPENBAO__NATS__NATS_RESULT_SUBJECT", "test.files.tabular_parsed")
|
||||
get_settings.cache_clear()
|
||||
app = create_app(get_settings())
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
pass
|
||||
|
||||
line = next(line for line in log_lines if "Subject'ы NATS" in line)
|
||||
assert "test.files.img_uploaded" in line and "test.files.tabular_parsed" in line
|
||||
|
||||
|
||||
async def test_start_in_local_mode(local_env: Path) -> None:
|
||||
"""Spec «Старт в режиме локальных папок»: 200 с uptime, version, nats, hotfolder = ok; OPENBAO__SMB__* не нужны."""
|
||||
app = create_app(get_settings())
|
||||
|
||||
@ -244,18 +244,43 @@ def test_nats_subjects_from_openbao(settings_from_env) -> None:
|
||||
assert nats.NATS_RESULT_SUBJECT == "test.files.tabular_parsed"
|
||||
|
||||
|
||||
SUBJECT_KEYS = ("NATS_TABULAR_REQUEST_SUBJECT", "NATS_IMAGE_REQUEST_SUBJECT", "NATS_RESULT_SUBJECT")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", SUBJECT_KEYS)
|
||||
@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_invalid_subject_rejected(settings_from_env, 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, first: str, second: str) -> None:
|
||||
with pytest.raises(ValidationError, match=f"{first} и {second} совпадают"):
|
||||
settings_from_env(**{f"OPENBAO__NATS__{first}": "test.same", f"OPENBAO__NATS__{second}": "test.same"})
|
||||
|
||||
|
||||
def test_invalid_subject_error_hides_nats_secrets(settings_from_env) -> None:
|
||||
"""Невалидный subject при заданных JWT и seed: секреты не попадают в текст ошибки валидации."""
|
||||
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)
|
||||
for secret in (JWT, SEED):
|
||||
assert secret not in text
|
||||
assert secret[:10] not in text
|
||||
assert secret[-10:] not in text
|
||||
|
||||
|
||||
def test_nats_secrets_hidden_in_repr(settings_from_env) -> None:
|
||||
|
||||
@ -28,6 +28,7 @@ from src.core.nats import (
|
||||
NatsGateway,
|
||||
NatsNotConnectedError,
|
||||
PublishFailed,
|
||||
build_nats_auth,
|
||||
used_subjects,
|
||||
)
|
||||
|
||||
@ -152,6 +153,22 @@ async def test_connect_with_nkeys_and_jwt_when_both_set(monkeypatch: pytest.Monk
|
||||
assert fake.jetstream_calls == 0
|
||||
|
||||
|
||||
def test_build_nats_auth_strips_whitespace_around_seed_and_jwt(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Пробелы и перевод строки по краям (типично для значений из OpenBao) срезаются: seed читается, JWT чистый."""
|
||||
keypair = nkeys.from_seed(nkeys.encode_seed(b"\x02" * 32, nkeys.PREFIX_BYTE_USER))
|
||||
settings = make_settings(
|
||||
monkeypatch,
|
||||
OPENBAO__NATS__NATS_SEED=f" {keypair.seed.decode()}\n",
|
||||
OPENBAO__NATS__NATS_JWT="\n dummy.jwt.token \n",
|
||||
)
|
||||
|
||||
kwargs = build_nats_auth(settings.OPENBAO.NATS)
|
||||
|
||||
assert kwargs["user_jwt_cb"]() == b"dummy.jwt.token"
|
||||
signature = base64.b64decode(kwargs["signature_cb"]("nonce-1"))
|
||||
assert keypair.verify(b"nonce-1", signature) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"env",
|
||||
[
|
||||
|
||||
@ -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` (`image_request`, `result`) при `nats = ok | down | off`; лог старта с subject'ами
|
||||
- [ ] 1.3 Тесты интеграционные (`tests/integration/`) — уникальные subject'ы через настройки
|
||||
- [x] 1.2 Unit: `/healthcheck.subjects` (`image_request`, `result`) при `nats = ok | down | off`; лог старта с subject'ами
|
||||
- [x] 1.3 Тесты интеграционные (`tests/integration/`) — уникальные 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