gendesign/backend/tests/test_auth_db.py
bot-backend 7be07efe70
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
feat(sitefinder): «Птица» принимает сессию общего реестра [PR-2c/6]
Дефолт 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).
2026-08-02 17:00:54 +03:00

497 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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"]