All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 15m13s
Дефолт AUTH_MODE=legacy — сегодняшнее поведение байт-в-байт: соединение с БД auth не открывается, кука не читается, отсутствие настроек не роняет старт. Popup Caddy стоит и снимается последним PR эпика — инвариант «гейт уходит последним» не нарушен. У Site Finder не было авторизации вообще: rbac_guard доверял заголовку X-Authenticated-User от Caddy. Теперь он умеет резолвить сессионную куку общего реестра. ВЫДАВАТЬ сессии «Птица» не будет — логин один, у «Меры», а кука host-only на gendsgn.ru с path=/ и так долетает до обоих продуктов. Меньше кода и меньше мест, где можно ошибиться. Режим трёхзначный, а не булев: legacy | dual | db_only. Это прямое следствие ревью. При булевом флаге фолбэк «сессия не нашлась → верим заголовку» после снятия popup превращался бы в полный обход аутентификации, и ничто в коде не заставило бы про него вспомнить. В db_only легаси-ветка недостижима: 401. Резолв уехал в threadpool. Три независимых ревьюера нашли одно и то же: sync-запрос к БД в async-guard блокирует event loop на каждом non-public запросе — ровно инцидент #1202, который в этом же файле уже лечили. Кука разбирается на loop'е, в поток уезжает только строка токена; запрос без куки не платит ни за поток, ни за коннект. Срок годности сессии считают часы БД, а не приложения. Раньше проверка шла в Python, а sliding-refresh переписывал строку через now() базы — при расхождении часов истёкшая сессия не просто проходила, а продлевалась заново, то есть воскресала навсегда. Теперь `expires_at > now()` в самом SELECT; питоновская проверка оставлена вторым поясом. Fail-fast на старте проверяет не синтаксис DSN, а живое соединение: SELECT 1. Иначе неверный пароль или хост выглядели бы как «ни у кого нет сессии» — сутками, потому что ошибку ловил бы except в guard'е. Ещё из ревью: connect_timeout и statement_timeout по 3с (недоступный хост вешал коннект на минуты); throttling логов сбоя реестра (иначе шторм в GlitchTip на каждый запрос); тела SQL закреплены ассертами формы — мутация любого фрагмента теперь красит тесты, до этого не красила ничего. Дефолт хоста БД — postgres, и это зеркально «Мере». У неё gendesign-postgres, потому что внутри её стека `postgres` — чужой контейнер; здесь стек главный, и `postgres` из корневого compose и есть нужный сервер. Алиас gendesign-postgres дефолтом был бы багом: контейнер beat состоит только в сети default и это имя из него не разрезолвится. Сверка реестра с ролевой картой сделана на живом проде: все 13 юзеров auth.users присутствуют в auth/roles.yaml, ни один не получит 403 на всё. Четыре QA-фикстуры (admintest, pilottest, analysttest, expiredtest) есть в yaml, но не в реестре — после снятия popup войти ими через браузер будет нельзя, только внутрисетевым заголовком. Осознанный долг, вписан ⚠️-блоком перед guard'ом: paths/deny из roles.yaml бэкендом не применяются (их энфорсит фронтовый RouteGuard), guard проверяет только известность username и admin-пути. Это предсуществующее поведение; менять его здесь значило бы изменить и легаси-ветку, то есть нарушить «дефолт = сегодня». Тесты: 4594 passed, 0 failed. Главный — подделка: валидная кука плюс присланный клиентом X-Authenticated-User другого пользователя, выигрывает кука, и роут, читающий заголовок напрямую, видит владельца куки. У «Птицы» таких прямых читателей одиннадцать, поэтому перезапись ASGI-scope обязана быть полной, а не skip-if-present (CRITICAL #2552).
497 lines
22 KiB
Python
497 lines
22 KiB
Python
"""DSN и ленивый engine БД `auth` — `app/core/config.py` + `app/core/auth_db.py`.
|
||
|
||
Эпик «единый вход», стадия 3. Три группы:
|
||
|
||
1. Дефолты. Они ЧАСТЬ КОНТРАКТА PR, а не декорация: пока Caddy basic_auth стоит,
|
||
прод обязан вести себя ровно как до эпика — флаг выключен, DSN не сконфигурирован,
|
||
engine не создаётся, отсутствие AUTH_* в окружении не роняет старт.
|
||
2. Сборка DSN из частей: приоритет явного URL, экранирование секрета, пустые
|
||
значения переменных → прод-дефолты (а не мусорный DSN и не падение на импорте).
|
||
3. `auth_db`: ленивость, кеш, внятная ошибка вместо утечки пароля.
|
||
|
||
Сеть здесь не нужна: `create_engine` пул создаёт лениво и к серверу не ходит.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
from collections.abc import Iterator
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from pydantic import SecretStr, ValidationError
|
||
from sqlalchemy.engine import make_url
|
||
|
||
from app.core import auth_db
|
||
from app.core.auth_db import AuthDatabaseNotConfiguredError, AuthDatabaseUnreachableError
|
||
from app.core.config import Settings, settings
|
||
|
||
_AUTH_ENV_VARS = (
|
||
"AUTH_MODE",
|
||
"AUTH_DATABASE_URL",
|
||
"AUTH_DB_PASSWORD",
|
||
"AUTH_DB_HOST",
|
||
"AUTH_DB_PORT",
|
||
"AUTH_DB_NAME",
|
||
"AUTH_DB_USER",
|
||
"SESSION_COOKIE_NAME",
|
||
"SESSION_TTL_HOURS",
|
||
)
|
||
|
||
# Заведомо синтаксически корректный DSN на несуществующий хост: engine по нему
|
||
# создаётся, но соединение не открывается (пул ленивый), поэтому тесты офлайновы.
|
||
_OFFLINE_DSN = "postgresql+psycopg://auth_app:pw@127.0.0.1:1/auth"
|
||
|
||
|
||
@pytest.fixture
|
||
def clean_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Ни одной AUTH_*/SESSION_* переменной — тест дефолтов не зависит от машины."""
|
||
for name in _AUTH_ENV_VARS:
|
||
monkeypatch.delenv(name, raising=False)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_engine_cache() -> Iterator[None]:
|
||
"""Ни один тест не оставляет за собой закешированный engine БД `auth`."""
|
||
auth_db.reset_auth_db()
|
||
yield
|
||
auth_db.reset_auth_db()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. Дефолты
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_session_mode_is_off_and_unconfigured_by_default(clean_env: None) -> None:
|
||
"""Свежие настройки без AUTH_*: режим legacy, DSN пуст — и это НЕ ошибка."""
|
||
fresh = Settings()
|
||
|
||
assert fresh.auth_mode == "legacy"
|
||
assert fresh.auth_session_enabled is False
|
||
assert fresh.resolved_auth_database_url == ""
|
||
|
||
|
||
def test_live_settings_singleton_is_off() -> None:
|
||
"""Тот же инвариант на настоящем синглтоне, которым пользуется приложение."""
|
||
assert settings.auth_mode == "legacy"
|
||
assert settings.auth_session_enabled is False
|
||
|
||
|
||
@pytest.mark.parametrize("raw", ["", " ", "LEGACY", " legacy "])
|
||
def test_blank_or_odd_case_auth_mode_falls_back_to_legacy(
|
||
clean_env: None, monkeypatch: pytest.MonkeyPatch, raw: str
|
||
) -> None:
|
||
"""`AUTH_MODE=` (или регистр/пробелы) → legacy, а не ValidationError на импорте.
|
||
|
||
`settings = Settings()` выполняется на уровне модуля: невалидное значение уронило бы
|
||
ИМПОРТ конфига и увело контейнер в restart-loop. Сценарий бытовой — ops копирует
|
||
блок AUTH_* в .env.runtime и заполняет только пароль.
|
||
"""
|
||
monkeypatch.setenv("AUTH_MODE", raw)
|
||
|
||
assert Settings().auth_mode == "legacy"
|
||
|
||
|
||
def test_meaningful_garbage_in_auth_mode_still_fails(
|
||
clean_env: None, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""`AUTH_MODE=off` — опечатка со смыслом, и она обязана падать.
|
||
|
||
Молча трактовать её как legacy значило бы тихо оставить продукт на trusted-header
|
||
после того, как последний PR эпика снимет popup.
|
||
"""
|
||
monkeypatch.setenv("AUTH_MODE", "off")
|
||
|
||
with pytest.raises(ValidationError):
|
||
Settings()
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("mode", "enabled"), [("legacy", False), ("dual", True), ("db_only", True)]
|
||
)
|
||
def test_auth_session_enabled_is_derived_from_mode(
|
||
clean_env: None, monkeypatch: pytest.MonkeyPatch, mode: str, enabled: bool
|
||
) -> None:
|
||
"""Свойство `auth_session_enabled` = «режим не legacy» — единый выключатель реестра.
|
||
|
||
Оно и закорачивает `resolve_session_token` / `require_auth_db_configured`; разница
|
||
dual vs db_only ему не видна и не должна быть (она про фолбэк в rbac_guard).
|
||
"""
|
||
monkeypatch.setenv("AUTH_MODE", mode)
|
||
|
||
assert Settings().auth_session_enabled is enabled
|
||
|
||
|
||
def test_default_host_is_this_stacks_postgres(clean_env: None) -> None:
|
||
"""🪤 Дефолт хоста — `postgres`, и это ЗЕРКАЛЬНО «Мере», а не копия с неё.
|
||
|
||
У «Меры» дефолт `gendesign-postgres`, потому что внутри её стека имя `postgres`
|
||
занято её собственным контейнером. У «Птицы» наоборот: её стек главный, сервис
|
||
`postgres` корневого docker-compose.prod.yml и есть сервер с БД `auth`. Алиас
|
||
`gendesign-postgres` живёт только во внешней сети `shared`, куда входят не все
|
||
сервисы (beat — нет), поэтому дефолтом он быть не может.
|
||
"""
|
||
fresh = Settings()
|
||
|
||
assert fresh.auth_db_host == "postgres"
|
||
assert fresh.auth_db_host != "gendesign-postgres"
|
||
assert fresh.auth_db_port == 5432
|
||
assert fresh.auth_db_name == "auth"
|
||
assert fresh.auth_db_user == "auth_app"
|
||
|
||
|
||
def test_cookie_defaults_match_the_other_product(clean_env: None) -> None:
|
||
"""Имя куки и TTL обязаны совпадать с «Мерой» — иначе общая сессия не общая.
|
||
|
||
Имя историческое («tradein_» уже ни о чём не говорит); переименование
|
||
разлогинивает всех сразу в обоих продуктах, поэтому оно закреплено тестом.
|
||
"""
|
||
fresh = Settings()
|
||
|
||
assert fresh.session_cookie_name == "tradein_session"
|
||
assert fresh.session_ttl_hours == 720
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. Сборка DSN
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_explicit_url_wins_over_parts(clean_env: None) -> None:
|
||
"""AUTH_DATABASE_URL — аварийный обход (другой хост, sslmode): выигрывает всегда."""
|
||
fresh = Settings(
|
||
auth_database_url=" postgresql+psycopg://u:p@elsewhere:6432/auth?sslmode=require ",
|
||
auth_db_password=SecretStr("ignored"),
|
||
auth_db_host="postgres",
|
||
)
|
||
|
||
assert (
|
||
fresh.resolved_auth_database_url
|
||
== "postgresql+psycopg://u:p@elsewhere:6432/auth?sslmode=require"
|
||
)
|
||
|
||
|
||
def test_dsn_built_from_password_and_defaults(clean_env: None) -> None:
|
||
"""Включение на проде = одна переменная: пароль + прод-дефолты остальных частей."""
|
||
fresh = Settings(auth_db_password=SecretStr("s3cret"))
|
||
|
||
assert (
|
||
fresh.resolved_auth_database_url
|
||
== "postgresql+psycopg://auth_app:s3cret@postgres:5432/auth"
|
||
)
|
||
|
||
|
||
def test_password_special_chars_survive_round_trip(clean_env: None) -> None:
|
||
"""Пароль экранируется: `@`/`/`/`:`/`#`/`%` иначе порвали бы URL по его грамматике.
|
||
|
||
Проверяем не наличие процентов в строке, а РАЗБОР обратно: важно, что SQLAlchemy
|
||
видит тот же пароль и, главное, тот же хост/базу. Незакавыченный `@` молча увёл бы
|
||
подключение на другой хост.
|
||
"""
|
||
raw = "p@ss:w/rd#1%zz?x"
|
||
url = make_url(Settings(auth_db_password=SecretStr(raw)).resolved_auth_database_url)
|
||
|
||
assert url.password == raw
|
||
assert url.host == "postgres"
|
||
assert url.port == 5432
|
||
assert url.database == "auth"
|
||
assert url.username == "auth_app"
|
||
|
||
|
||
def test_username_is_quoted_too(clean_env: None) -> None:
|
||
url = make_url(
|
||
Settings(
|
||
auth_db_password=SecretStr("pw"), auth_db_user="odd:user@name"
|
||
).resolved_auth_database_url
|
||
)
|
||
|
||
assert url.username == "odd:user@name"
|
||
assert url.host == "postgres"
|
||
|
||
|
||
def test_password_whitespace_is_preserved_not_stripped(clean_env: None) -> None:
|
||
"""Ведущий/хвостовой пробел может быть частью настоящего пароля — не режем."""
|
||
url = make_url(Settings(auth_db_password=SecretStr(" pw ")).resolved_auth_database_url)
|
||
|
||
assert url.password == " pw "
|
||
|
||
|
||
def test_blank_password_means_not_configured(clean_env: None) -> None:
|
||
"""Пробельная строка — опечатка в .env, а не пароль: «не сконфигурировано»."""
|
||
assert Settings(auth_db_password=SecretStr(" ")).resolved_auth_database_url == ""
|
||
assert Settings(auth_db_password=SecretStr("")).resolved_auth_database_url == ""
|
||
|
||
|
||
def test_blank_parts_fall_back_to_defaults(clean_env: None) -> None:
|
||
"""`AUTH_DB_HOST=` в .env.runtime не должен давать DSN вида `...@:5432/auth`.
|
||
|
||
Сценарий бытовой: ops копирует блок AUTH_DB_* целиком и заполняет только пароль.
|
||
"""
|
||
url = make_url(
|
||
Settings(
|
||
auth_db_password=SecretStr("pw"),
|
||
auth_db_host=" ",
|
||
auth_db_name="",
|
||
auth_db_user=" ",
|
||
).resolved_auth_database_url
|
||
)
|
||
|
||
assert (url.host, url.database, url.username) == ("postgres", "auth", "auth_app")
|
||
|
||
|
||
@pytest.mark.parametrize("blank", ["", " "])
|
||
def test_blank_port_does_not_break_import(clean_env: None, blank: str) -> None:
|
||
"""`AUTH_DB_PORT=` → дефолт, а НЕ ValidationError.
|
||
|
||
`settings = Settings()` выполняется на уровне модуля: падение здесь уводило бы
|
||
контейнер в restart-loop — причём в дефолтном режиме, где к БД `auth` не идёт ни
|
||
одного обращения.
|
||
"""
|
||
assert Settings(auth_db_port=blank).auth_db_port == 5432
|
||
|
||
|
||
def test_non_blank_garbage_port_still_fails(clean_env: None) -> None:
|
||
"""`AUTH_DB_PORT=abc` — опечатка со смыслом, её глушить нельзя."""
|
||
with pytest.raises(ValueError):
|
||
Settings(auth_db_port="abc")
|
||
|
||
|
||
def test_password_is_not_printed_by_repr_or_dump(clean_env: None) -> None:
|
||
"""SecretStr: пароль не утекает в `repr(settings)` / `model_dump()`.
|
||
|
||
Сегодня их никто не рендерит, но появиться такой рендер (лог старта, /debug) может
|
||
тихо — а рядом с обычным str-полем это была бы утечка секрета в открытый лог.
|
||
"""
|
||
fresh = Settings(auth_db_password=SecretStr("s3cret"))
|
||
|
||
assert "s3cret" not in repr(fresh)
|
||
assert "s3cret" not in str(fresh.model_dump())
|
||
assert fresh.auth_db_password.get_secret_value() == "s3cret"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. auth_db: ленивость, кеш, ошибки
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_import_without_any_auth_env_does_not_build_engine() -> None:
|
||
"""Импорт в чистом окружении: ошибки нет, engine не создан, DSN пуст.
|
||
|
||
Проверяется отдельным процессом, потому что в текущем модуль импортирован давно и
|
||
любое утверждение про «на импорте» было бы про уже случившийся импорт. Это отличие
|
||
от `app.core.db`, где engine создаётся в теле модуля: сделай мы так же, приложение
|
||
падало бы на старте везде, где реестр не сконфигурирован — локально, в pytest, на
|
||
любом стенде. Ровно тот контракт, который держит дефолтное поведение прода.
|
||
"""
|
||
env = {k: v for k, v in os.environ.items() if k not in _AUTH_ENV_VARS}
|
||
code = (
|
||
"from app.core import auth_db\n"
|
||
"from app.core.config import settings\n"
|
||
"print(auth_db._engine, repr(settings.resolved_auth_database_url), "
|
||
"settings.auth_session_enabled)\n"
|
||
)
|
||
proc = subprocess.run(
|
||
[sys.executable, "-c", code],
|
||
cwd=Path(__file__).resolve().parents[1],
|
||
env=env,
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
|
||
assert proc.returncode == 0, proc.stderr
|
||
assert proc.stdout.strip() == "None '' False"
|
||
|
||
|
||
def test_unconfigured_registry_raises_with_actionable_message(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Пустой DSN → явная ошибка с инструкцией, а не «сессия не найдена»."""
|
||
monkeypatch.setattr(settings, "auth_database_url", "")
|
||
monkeypatch.setattr(settings, "auth_db_password", SecretStr(""))
|
||
|
||
with pytest.raises(AuthDatabaseNotConfiguredError) as excinfo:
|
||
auth_db.get_auth_engine()
|
||
|
||
assert "AUTH_MODE" in str(excinfo.value)
|
||
assert "AUTH_DB_PASSWORD" in str(excinfo.value)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"broken",
|
||
[
|
||
"not-a-dsn-at-all",
|
||
# «Почти URL»: разбор доходит до int(port) и падает, унося в текст ошибки
|
||
# кусок пароля, съехавший на позицию порта.
|
||
"postgresql+psycopg://u:pa@ss@host:wo/auth",
|
||
],
|
||
)
|
||
def test_malformed_dsn_does_not_leak_into_the_error(
|
||
monkeypatch: pytest.MonkeyPatch, broken: str
|
||
) -> None:
|
||
"""Битый DSN → своя ошибка БЕЗ самого DSN и без исходного traceback.
|
||
|
||
Текст ошибки SQLAlchemy цитирует строку целиком, а в ней пароль роли auth_app.
|
||
`from None` обязателен: без него исходная ошибка печаталась бы в traceback как
|
||
«During handling of the above exception...» — то есть пароль всё равно оказался бы
|
||
в логе.
|
||
"""
|
||
monkeypatch.setattr(settings, "auth_database_url", broken)
|
||
|
||
with pytest.raises(AuthDatabaseNotConfiguredError) as excinfo:
|
||
auth_db.get_auth_engine()
|
||
|
||
assert broken not in str(excinfo.value)
|
||
assert "pa@ss" not in str(excinfo.value)
|
||
assert excinfo.value.__suppress_context__ is True
|
||
assert excinfo.value.__cause__ is None
|
||
|
||
|
||
def test_engine_is_built_once_and_reused(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN)
|
||
|
||
first = auth_db.get_auth_engine()
|
||
second = auth_db.get_auth_engine()
|
||
|
||
assert first is second
|
||
assert auth_db.get_auth_session_factory().kw["bind"] is first
|
||
assert first.url.database == "auth"
|
||
|
||
|
||
def test_reset_drops_the_cached_engine(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN)
|
||
first = auth_db.get_auth_engine()
|
||
|
||
auth_db.reset_auth_db()
|
||
|
||
assert auth_db._engine is None
|
||
assert auth_db.get_auth_engine() is not first
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# require_auth_db_configured — fail-fast на старте (lifespan)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_startup_check_is_noop_while_flag_is_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""🔒 Дефолт: пустой DSN на старте — не ошибка, и engine не создаётся.
|
||
|
||
Ровно то, что произойдёт на проде сразу после мержа этого PR.
|
||
"""
|
||
monkeypatch.setattr(settings, "auth_mode", "legacy")
|
||
monkeypatch.setattr(settings, "auth_database_url", "")
|
||
monkeypatch.setattr(settings, "auth_db_password", SecretStr(""))
|
||
|
||
auth_db.require_auth_db_configured()
|
||
|
||
assert auth_db._engine is None
|
||
|
||
|
||
def test_startup_check_fails_fast_when_enabled_without_dsn(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Флаг включили, DSN не задали → контейнер не стартует.
|
||
|
||
Иначе пустой DSN ловил бы `except` в guard'е, и сломанная конфигурация выглядела бы
|
||
как «ни у кого нет сессии» — сутками, при живом приложении и 200-х в ответах.
|
||
"""
|
||
monkeypatch.setattr(settings, "auth_mode", "dual")
|
||
monkeypatch.setattr(settings, "auth_database_url", "")
|
||
monkeypatch.setattr(settings, "auth_db_password", SecretStr(""))
|
||
|
||
with pytest.raises(AuthDatabaseNotConfiguredError):
|
||
auth_db.require_auth_db_configured()
|
||
|
||
|
||
def test_startup_check_builds_engine_and_probes_connection(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Режим включён и реестр отвечает → engine готов ещё до первого запроса.
|
||
|
||
Проба соединения подменена: поднимать Postgres ради этого теста незачем, важно, что
|
||
она вызывается ИМЕННО на том engine, который останется закешированным.
|
||
"""
|
||
monkeypatch.setattr(settings, "auth_mode", "dual")
|
||
monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN)
|
||
probed: list[object] = []
|
||
monkeypatch.setattr(auth_db, "_probe_connection", probed.append)
|
||
|
||
auth_db.require_auth_db_configured()
|
||
|
||
assert auth_db._engine is not None
|
||
assert probed == [auth_db._engine]
|
||
|
||
|
||
def test_startup_check_fails_when_dsn_parses_but_connection_does_not(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""🔴 Смысл проверки: DSN разобрался — это ещё НЕ «реестр рабочий».
|
||
|
||
`create_engine` к серверу не ходит, поэтому одна лишь сборка engine отлавливала бы
|
||
ровно два случая (DSN пуст / не парсится). Весь вероятный класс ошибок — неверный
|
||
AUTH_DB_PASSWORD, опечатка в хосте, не созданная БД `auth`, отозванная роль
|
||
auth_app, нет сети — проходил бы мимо, контейнер стартовал бы зелёным, `/health`
|
||
отвечал бы 200, а каждый запрос с кукой молча деградировал бы на легаси-заголовок.
|
||
Сутками. Ровно то, что комментарий в app/main.py обещает НЕ допускать.
|
||
"""
|
||
monkeypatch.setattr(settings, "auth_mode", "dual")
|
||
monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN)
|
||
|
||
def _refused(_engine: object) -> None:
|
||
raise OSError("connection to server at 127.0.0.1, port 1 failed: Connection refused")
|
||
|
||
monkeypatch.setattr(auth_db, "_probe_connection", _refused)
|
||
|
||
with pytest.raises(AuthDatabaseUnreachableError) as excinfo:
|
||
auth_db.require_auth_db_configured()
|
||
|
||
# Причина сохранена в цепочке — ради неё проверка и делается; DSN (в нём пароль) в
|
||
# наш текст не подставляется.
|
||
assert isinstance(excinfo.value.__cause__, OSError)
|
||
assert "AUTH_MODE" in str(excinfo.value)
|
||
assert _OFFLINE_DSN not in str(excinfo.value)
|
||
|
||
|
||
def test_startup_check_does_not_probe_while_flag_is_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""🔒 legacy: ни коннекта, ни пробы — даже если DSN задан и валиден."""
|
||
monkeypatch.setattr(settings, "auth_mode", "legacy")
|
||
monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN)
|
||
|
||
def _boom(_engine: object) -> None:
|
||
raise AssertionError("в режиме legacy соединение с реестром недопустимо")
|
||
|
||
monkeypatch.setattr(auth_db, "_probe_connection", _boom)
|
||
|
||
auth_db.require_auth_db_configured()
|
||
|
||
assert auth_db._engine is None
|
||
|
||
|
||
def test_engine_has_short_timeouts(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Реестр — не критический путь: его сбой обязан деградировать за секунды.
|
||
|
||
Без `connect_timeout` дропнутые SYN (фаервол молча глотает пакеты) держали бы
|
||
попытку до TCP-таймаута ОС — на Linux ~130 с, и так на КАЖДОМ checkout'е, потому
|
||
что включён `pool_pre_ping`. `pool_timeout` по дефолту 30 с — в auth-пути столько
|
||
ждать свободный коннект незачем.
|
||
"""
|
||
monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN)
|
||
captured: dict[str, object] = {}
|
||
real_create_engine = auth_db.create_engine
|
||
|
||
def _spy(dsn: str, **kwargs: object) -> object:
|
||
captured.update(kwargs)
|
||
return real_create_engine(dsn, **kwargs) # type: ignore[arg-type]
|
||
|
||
monkeypatch.setattr(auth_db, "create_engine", _spy)
|
||
|
||
auth_db.get_auth_engine()
|
||
|
||
assert captured["pool_timeout"] == 3
|
||
assert captured["pool_pre_ping"] is True
|
||
connect_args = captured["connect_args"]
|
||
assert isinstance(connect_args, dict)
|
||
assert connect_args["connect_timeout"] == 3
|
||
assert "statement_timeout=3000" in connect_args["options"]
|