Тесты 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
|
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:
|
async def test_start_in_local_mode(local_env: Path) -> None:
|
||||||
"""Spec «Старт в режиме локальных папок»: 200 с uptime, version, nats, hotfolder = ok; OPENBAO__SMB__* не нужны."""
|
"""Spec «Старт в режиме локальных папок»: 200 с uptime, version, nats, hotfolder = ok; OPENBAO__SMB__* не нужны."""
|
||||||
app = create_app(get_settings())
|
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"
|
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(
|
@pytest.mark.parametrize(
|
||||||
"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:
|
def test_invalid_subject_rejected(settings_from_env, key: str, value: str) -> None:
|
||||||
with pytest.raises(ValidationError, match="NATS_RESULT_SUBJECT"):
|
with pytest.raises(ValidationError, match=key):
|
||||||
settings_from_env(OPENBAO__NATS__NATS_RESULT_SUBJECT=value)
|
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, 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:
|
def test_nats_secrets_hidden_in_repr(settings_from_env) -> None:
|
||||||
|
|||||||
@ -28,6 +28,7 @@ from src.core.nats import (
|
|||||||
NatsGateway,
|
NatsGateway,
|
||||||
NatsNotConnectedError,
|
NatsNotConnectedError,
|
||||||
PublishFailed,
|
PublishFailed,
|
||||||
|
build_nats_auth,
|
||||||
used_subjects,
|
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
|
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(
|
@pytest.mark.parametrize(
|
||||||
"env",
|
"env",
|
||||||
[
|
[
|
||||||
|
|||||||
@ -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` (`image_request`, `result`) при `nats = ok | down | off`; лог старта с subject'ами
|
- [x] 1.2 Unit: `/healthcheck.subjects` (`image_request`, `result`) при `nats = ok | down | off`; лог старта с subject'ами
|
||||||
- [ ] 1.3 Тесты интеграционные (`tests/integration/`) — уникальные subject'ы через настройки
|
- [x] 1.3 Тесты интеграционные (`tests/integration/`) — уникальные 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