gendesign/tradein-mvp/backend/tests/test_auth_dsn_from_parts.py
bot-backend 8db67fe4d0
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m43s
feat(tradein): DSN к БД auth собирается из AUTH_DB_PASSWORD, а не дублирует секрет
Чтобы включить IDENTITY_STORE=auth, до этого требовалось положить в
runtime-окружение полный AUTH_DATABASE_URL с паролем внутри — при том что
пароль уже лежит там же отдельной переменной AUTH_DB_PASSWORD (её читает
deploy-пайплайн для ALTER ROLE). Один секрет в двух местах разъезжается:
сменили пароль роли, DSN остался старым — вход ложится молча и целиком.

Теперь явный AUTH_DATABASE_URL по-прежнему выигрывает (обратная совместимость
и аварийный обход, скажем sslmode); если он пуст, а AUTH_DB_PASSWORD задан,
DSN собирается из частей. Части переопределяемы через AUTH_DB_HOST, _PORT,
_NAME, _USER.

Дефолт хоста — gendesign-postgres, не postgres. Внутри стека «Меры» имя
postgres резолвится в ЕЁ СОБСТВЕННЫЙ контейнер (tradein-postgres), и такой
дефолт не упал бы «неизвестным хостом», а молча увёл бы аутентификацию в живую
БД tradein, где нет ни роли auth_app, ни таблиц реестра. Нужный сервер виден по
алиасу gendesign-postgres в сети gendesign_shared, к которой tradein-backend
подписан.

Пароль и имя пользователя экранируются quote(safe=""). Имя БД и хост —
намеренно нет: SQLAlchemy раскодирует обратно только userinfo, а path отдаёт
как есть, поэтому quote("c/d") уехало бы в сервер литеральным c%2Fd. Найдено
прогоном, закреплено тестом.

Закрыта реальная утечка на пути ЯВНОГО AUTH_DATABASE_URL: на «почти URL»
SQLAlchemy доходит до int(port) и падает ValueError с символом ПАРОЛЯ в тексте
(он съезжает на позицию порта). Без обрыва цепочки обломок печатался бы в
traceback, то есть в логи и GlitchTip. Теперь ValueError и ArgumentError
перевыбрасываются своим сообщением from None; тест рендерит traceback целиком и
проверяет, что пароля там нет.

Пустое значение AUTH_DB_PORT больше не роняет импорт. Порт типизирован int и
валидируется до всякой нашей логики, а settings создаётся на уровне модуля —
пустая строка уводила контейнер в restart-loop В ЛЮБОМ режиме, включая
дефолтный tradein, где к БД auth нет ни одного обращения.

Прод не меняется: при IDENTITY_STORE=tradein (дефолт) ничего из этого не
читается и соединение с auth не открывается.

Тесты: +16 профильных, 118 passed на связке auth-сьютов. Проверено
исполнением: пустой порт даёт 5432; пароль со спецсимволами экранируется и в
открытом виде в DSN не встречается.
2026-08-02 12:05:41 +03:00

469 lines
25 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 БД `auth` из частей: один секрет — одно место (эпик «единый вход»).
Зачем это вообще. Пароль роли `auth_app` уже лежит в `.env.runtime` отдельной
переменной `AUTH_DB_PASSWORD` — её читает `.forgejo/workflows/deploy.yml`, чтобы
сделать `ALTER ROLE`. Требовать вдобавок целиковый `AUTH_DATABASE_URL` с тем же
паролем внутри значило бы держать ОДИН секрет в ДВУХ местах: ротировали пароль
роли, забыли переписать DSN — и вход ложится молча и целиком, у всех сразу.
Поэтому DSN собирается из частей, а явный `AUTH_DATABASE_URL` остаётся
приоритетным аварийным обходом.
Что пинят тесты ниже:
1. Дефолтный режим (`IDENTITY_STORE=tradein`) НЕ требует ни одной новой
переменной — прод после мержа работает ровно как сейчас.
2. Хост по умолчанию — `gendesign-postgres`, НЕ `postgres`. Внутри стека
«Меры» имя `postgres` резолвится в её собственный контейнер (см. коммент у
констант в `app/core/config.py`), и дефолт `postgres` увёл бы аутентификацию
в продуктовую БД, где нет ни роли, ни таблиц реестра.
3. Явный `AUTH_DATABASE_URL` бьёт сборку из частей.
4. Пароль экранируется: спецсимвол внутри него не имеет права порвать URL —
иначе разбор молча уедет на другой хост/базу.
5. Пароль НЕ попадает ни в текст исключения, ни в traceback — и ни в
`repr(settings)` / `model_dump()` (поле — `SecretStr`).
6. Ни одна новая переменная не способна уронить СТАРТ процесса: пустое
значение любой части (включая `int`-порт, который валидируется на импорте)
падает обратно на дефолт, а не в ValidationError.
⚠️ Все «пароли» в этом файле — синтетические строки для проверки экранирования,
не секреты (настоящий живёт только в `.env.runtime` на VPS).
"""
from __future__ import annotations
import os
import traceback
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from pydantic import SecretStr
from sqlalchemy.engine.url import make_url
from app.core import auth_db, config
from tests.support.identity_modes import use_identity_mode
# Набор символов, каждый из которых имеет СВОЙ смысл в грамматике URL:
# `@` — граница userinfo/host, `:` — граница user/password и host/port,
# `/` — начало пути (имени БД), `?` — начало query, `#` — начало фрагмента,
# `%` — начало процентной последовательности.
_SPECIALS_PASSWORD = "p@ss:w/o?rd#1%"
# Все переменные, которые новый код читает из окружения: в тестах, пинящих
# ДЕФОЛТЫ КОДА, их нужно снести — на дев-машине/CI они могут быть заданы.
_AUTH_ENV_VARS = (
"AUTH_DATABASE_URL",
"AUTH_DB_PASSWORD",
"AUTH_DB_HOST",
"AUTH_DB_PORT",
"AUTH_DB_NAME",
"AUTH_DB_USER",
"IDENTITY_STORE",
)
@pytest.fixture(autouse=True)
def _clean_auth_config(monkeypatch: pytest.MonkeyPatch):
"""Чистая конфигурация реестра до и после каждого теста.
Engine БД `auth` живёт в module-global, а не в `settings`, поэтому
monkeypatch его не откатывает — сбрасываем явно с обеих сторон, иначе
построенный здесь engine утёк бы в соседние тесты сьюта.
"""
auth_db.reset_auth_db()
monkeypatch.setattr(config.settings, "identity_store", "tradein")
monkeypatch.setattr(config.settings, "auth_database_url", "")
monkeypatch.setattr(config.settings, "auth_db_password", SecretStr(""))
yield
auth_db.reset_auth_db()
def _fresh_settings(monkeypatch: pytest.MonkeyPatch, **env: str) -> config.Settings:
"""Настройки, собранные ЗАНОВО из чистого окружения + *env*.
`_env_file=None` — не читать локальный `.env` (дев-машина и CI держат там
своё): пиним то, что записано литералом в `Settings`, а не окружение.
"""
for name in _AUTH_ENV_VARS:
monkeypatch.delenv(name, raising=False)
for name, value in env.items():
monkeypatch.setenv(name, value)
return config.Settings(_env_file=None) # type: ignore[call-arg]
def _set_password(monkeypatch: pytest.MonkeyPatch, value: str) -> None:
"""Подменить пароль на ЖИВОМ `settings` (для тестов, идущих через auth_db).
Обязательно через `SecretStr`: поле объявлено секретным, а `validate_assignment`
у `Settings` выключен — `monkeypatch.setattr` кладёт объект КАК ЕСТЬ, без
приведения типа. Голая строка тихо прошла бы присваивание и упала бы уже в
резолвере на `.get_secret_value()`.
"""
monkeypatch.setattr(config.settings, "auth_db_password", SecretStr(value))
# ---------------------------------------------------------------------------
# Дефолт: новая механика ничего не требует
# ---------------------------------------------------------------------------
def test_nothing_configured_means_empty_dsn(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ни одной переменной — DSN пуст, и это не ошибка.
Главный инвариант обратной совместимости: прод сегодня живёт с
`IDENTITY_STORE=tradein` и без всяких AUTH_*-переменных. Появление сборки из
частей не имеет права ни сделать что-то обязательным, ни начать угадывать
пароль.
"""
fresh = _fresh_settings(monkeypatch)
assert fresh.identity_store == "tradein"
assert fresh.auth_db_password.get_secret_value() == ""
assert fresh.resolved_auth_database_url == "", (
"без AUTH_DATABASE_URL и без AUTH_DB_PASSWORD реестр обязан считаться "
"несконфигурированным — иначе дефолтный режим полез бы в БД `auth`"
)
def test_default_mode_never_builds_engine_even_with_password(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`AUTH_DB_PASSWORD` сам по себе НЕ включает новый реестр.
Переменная может приехать в `.env.runtime` заранее (deploy.yml ставит ею
пароль роли задолго до переключения) — это не должно ничего активировать.
Переключатель ровно один: `IDENTITY_STORE`.
"""
_set_password(monkeypatch, _SPECIALS_PASSWORD)
assert config.settings.identity_store == "tradein"
assert auth_db._engine is None
assert auth_db._session_factory is None
# ---------------------------------------------------------------------------
# Сборка из частей
# ---------------------------------------------------------------------------
def test_dsn_assembled_from_password_and_prod_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
"""Задан только пароль — остальное берётся из прод-дефолтов."""
fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD="parts-only")
assert (
fresh.resolved_auth_database_url
== "postgresql+psycopg://auth_app:parts-only@gendesign-postgres:5432/auth"
)
def test_default_host_is_shared_alias_not_own_postgres(monkeypatch: pytest.MonkeyPatch) -> None:
"""Хост по умолчанию — алиас чужого стека, а НЕ `postgres`.
⚠️ Самая дорогая из возможных ошибок дефолта. `postgres` внутри «Меры» —
это её собственный контейнер (`tradein-mvp/docker-compose.prod.yml` собирает
им продуктовый DATABASE_URL), а БД `auth` живёт на постгресе главного стека,
видном по алиасу `gendesign-postgres` в сети `gendesign_shared` (корневой
`docker-compose.prod.yml`). Подключение по `postgres` не упало бы «не тем»
хостом — оно бы ушло в живую БД, где просто нет ни роли, ни таблиц реестра.
"""
url = make_url(_fresh_settings(monkeypatch, AUTH_DB_PASSWORD="x").resolved_auth_database_url)
assert url.host == "gendesign-postgres"
assert url.host != "postgres"
assert url.port == 5432
assert url.database == "auth"
assert url.username == "auth_app"
def test_scheme_matches_product_dsn_psycopg_v3(monkeypatch: pytest.MonkeyPatch) -> None:
"""Схема — та же, что у основного DATABASE_URL: psycopg v3.
`postgresql://` без суффикса увёл бы SQLAlchemy на psycopg2, которого нет в
зависимостях (`ModuleNotFoundError` на первом же обращении к реестру).
"""
dsn = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD="x").resolved_auth_database_url
assert dsn.startswith("postgresql+psycopg://")
assert make_url(dsn).drivername == make_url(config.settings.database_url).drivername
def test_parts_are_overridable_via_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Каждая часть переопределяется своей переменной (dev / SSH-туннель)."""
fresh = _fresh_settings(
monkeypatch,
AUTH_DB_PASSWORD="tunnel",
AUTH_DB_HOST="localhost",
AUTH_DB_PORT="15432",
AUTH_DB_NAME="auth_copy",
AUTH_DB_USER="reader",
)
assert (
fresh.resolved_auth_database_url
== "postgresql+psycopg://reader:tunnel@localhost:15432/auth_copy"
)
def test_blank_part_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None:
"""`AUTH_DB_HOST=` (пустая строка в .env) — опечатка, а не «хост пустой».
Без этого получился бы DSN `...@:5432/auth`, который разберётся и уедет
коннектиться в непредсказуемое место вместо внятной ошибки.
ПОРТ здесь же и намеренно: он единственный из частей типизирован `int`, и
правило «пусто → дефолт» держится для него отдельным валидатором. Читатель
обоснованно распространяет правило на всю семью AUTH_DB_* — пусть тест это и
подтверждает, а не только host/user.
"""
fresh = _fresh_settings(
monkeypatch,
AUTH_DB_PASSWORD="x",
AUTH_DB_HOST=" ",
AUTH_DB_USER="",
AUTH_DB_PORT="",
AUTH_DB_NAME=" ",
)
url = make_url(fresh.resolved_auth_database_url)
assert url.host == "gendesign-postgres"
assert url.username == "auth_app"
assert url.port == 5432
assert url.database == "auth"
def test_blank_port_does_not_break_default_mode(monkeypatch: pytest.MonkeyPatch) -> None:
"""`AUTH_DB_PORT=` не имеет права ронять КОНФИГ — тем более в режиме tradein.
⚠️ Тут пинится не DSN, а старт процесса. `settings = Settings()` выполняется
на уровне модуля `app/core/config.py`, а `int`-поле валидируется pydantic'ом
ДО всякой логики резолвера: без `_blank_port_means_default` пустая строка
давала бы ValidationError НА ИМПОРТЕ — то есть не отказ auth-пути, а
restart-loop контейнера. И это при `IDENTITY_STORE=tradein`, где новая
механика не должна читаться вообще.
Сценарий ровно тот, ради которого дефолты и заводились: ops кладёт в
.env.runtime шаблон блока AUTH_DB_*, заполняя только пароль.
"""
fresh = _fresh_settings(monkeypatch, AUTH_DB_PORT="")
assert fresh.auth_db_port == 5432
assert fresh.identity_store == "tradein"
assert fresh.resolved_auth_database_url == ""
def test_garbage_port_still_fails_loudly(monkeypatch: pytest.MonkeyPatch) -> None:
"""`AUTH_DB_PORT=abc` обязан падать: это опечатка со смыслом, не «пусто».
Граница послабления: пустую строку мы прощаем (её оставляют намеренно),
непустой мусор — нет, иначе тихо уехали бы на 5432 мимо того порта, который
человек имел в виду.
"""
with pytest.raises(ValueError):
_fresh_settings(monkeypatch, AUTH_DB_PORT="abc")
def test_whitespace_only_password_is_not_configured(monkeypatch: pytest.MonkeyPatch) -> None:
"""Пароль из одних пробелов = не задан (симметрично пустому DSN)."""
assert _fresh_settings(monkeypatch, AUTH_DB_PASSWORD=" ").resolved_auth_database_url == ""
# ---------------------------------------------------------------------------
# Приоритет явного DSN
# ---------------------------------------------------------------------------
def test_explicit_dsn_wins_over_parts(monkeypatch: pytest.MonkeyPatch) -> None:
"""Явный `AUTH_DATABASE_URL` выигрывает — обратная совместимость + обход.
Кто уже настроил стек по-старому, не должен ничего менять; и остаётся
аварийный путь вписать нестандартный DSN (другой хост, `sslmode`, пул-байпас)
без правки кода.
"""
explicit = "postgresql+psycopg://other:whole-dsn@elsewhere:6432/auth?sslmode=require"
fresh = _fresh_settings(
monkeypatch,
AUTH_DATABASE_URL=explicit,
AUTH_DB_PASSWORD="parts-must-lose",
AUTH_DB_HOST="ignored-host",
)
assert fresh.resolved_auth_database_url == explicit
def test_explicit_dsn_is_stripped_and_blank_falls_through_to_parts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""DSN из одних пробелов = не задан → сборка из частей, а не пустота.
Иначе `AUTH_DATABASE_URL=` (оставленная в файле пустая строка) заблокировала
бы работающий пароль и дала бы отказ входа на пустом месте.
"""
fresh = _fresh_settings(monkeypatch, AUTH_DATABASE_URL=" ", AUTH_DB_PASSWORD="fallback")
assert make_url(fresh.resolved_auth_database_url).password == "fallback"
# ---------------------------------------------------------------------------
# Экранирование
# ---------------------------------------------------------------------------
def test_special_chars_in_password_survive_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
"""`@ : / ? # %` в пароле не рвут URL — разбор возвращает его дословно.
Каждый из этих символов — разделитель в грамматике URL. Без экранирования
`@` сдвинул бы границу хоста, `/` открыл бы имя БД, `%` дал бы битую
процентную последовательность. Проверка — round-trip через тот же парсер,
которым пользуется SQLAlchemy при создании engine.
"""
fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD=_SPECIALS_PASSWORD)
dsn = fresh.resolved_auth_database_url
assert "p%40ss%3Aw%2Fo%3Frd%231%25" in dsn, "пароль обязан быть percent-encoded"
assert _SPECIALS_PASSWORD not in dsn, "сырой пароль в DSN = незаэкранированные разделители"
url = make_url(dsn)
assert url.password == _SPECIALS_PASSWORD
# И, главное, разделители из пароля не увели разбор в другое место:
assert (url.username, url.host, url.port, url.database) == (
"auth_app",
"gendesign-postgres",
5432,
"auth",
)
def test_special_chars_in_user_are_escaped(monkeypatch: pytest.MonkeyPatch) -> None:
"""Имя пользователя экранируется по той же причине, что и пароль.
`@` в имени роли иначе сдвинул бы границу userinfo/host — и коннект молча
пошёл бы не туда.
"""
fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD="x", AUTH_DB_USER="a@b")
url = make_url(fresh.resolved_auth_database_url)
assert url.username == "a@b"
assert url.host == "gendesign-postgres"
def test_dbname_is_passed_through_unescaped(monkeypatch: pytest.MonkeyPatch) -> None:
"""Имя БД НЕ percent-энкодится — иначе в сервер уедет литеральное `%2F`.
Асимметрия не случайна и легко читается как баг: SQLAlchemy раскодирует
обратно только userinfo (user/password), а path отдаёт как есть. Пропусти мы
имя БД через `quote`, `c/d` превратилось бы в `c%2Fd` уже НА СТОРОНЕ
ПОСТГРЕСА (`database "c%2Fd" does not exist`). Тест пинит именно round-trip.
"""
fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD="x", AUTH_DB_NAME="c/d")
assert make_url(fresh.resolved_auth_database_url).database == "c/d"
def test_engine_from_parts_carries_exact_password(monkeypatch: pytest.MonkeyPatch) -> None:
"""Сквозная проверка: engine строится из частей и несёт ИМЕННО тот пароль.
`create_engine` к серверу не ходит (пул ленивый), поэтому живая БД не нужна —
но URL внутри engine уже разобран SQLAlchemy, то есть это проверка всей
цепочки «части → экранирование → разбор», а не только строки.
"""
use_identity_mode(monkeypatch, "auth")
_set_password(monkeypatch, _SPECIALS_PASSWORD)
engine = auth_db.get_auth_engine()
assert engine.url.password == _SPECIALS_PASSWORD
assert engine.url.host == "gendesign-postgres"
assert engine.url.database == "auth"
# repr URL маскирует пароль — на этом держится безопасность чужих логов.
assert _SPECIALS_PASSWORD not in repr(engine.url)
# ---------------------------------------------------------------------------
# Ошибки: явные, но без секрета внутри
# ---------------------------------------------------------------------------
def test_auth_mode_without_password_and_dsn_raises(monkeypatch: pytest.MonkeyPatch) -> None:
"""Режим `auth` без конфигурации — явная ошибка, как и до появления частей.
Тихий фолбэк был бы худшим исходом: вход «работал» бы по неактуальному
реестру либо молча отказывал бы всем под видом неверных паролей.
"""
use_identity_mode(monkeypatch, "auth")
with pytest.raises(auth_db.AuthDatabaseNotConfiguredError) as excinfo:
auth_db.get_auth_engine()
message = str(excinfo.value)
# Текст обязан называть ОБА пути конфигурации — иначе дежурный будет искать
# переменную, которую мы же и перестали требовать.
assert "AUTH_DB_PASSWORD" in message
assert "AUTH_DATABASE_URL" in message
assert "IDENTITY_STORE=tradein" in message
def test_malformed_explicit_dsn_never_leaks_password(monkeypatch: pytest.MonkeyPatch) -> None:
"""Нечитаемый DSN → своя ошибка; ни пароля, ни его обломков нигде.
Ловушка, ради которой существует `from None`: на «почти URL» разбор
SQLAlchemy доходит до `int(port)` и падает с `invalid literal for int() with
base 10: 'w'`, где `'w'` — символ ПАРОЛЯ, съехавший на позицию порта. Без
обрыва цепочки исключений он всплыл бы в traceback («During handling of the
above exception...») — то есть в логи и в GlitchTip.
"""
use_identity_mode(monkeypatch, "auth")
monkeypatch.setattr(
config.settings,
"auth_database_url",
f"garbage://auth_app:{_SPECIALS_PASSWORD}@gendesign-postgres/auth",
)
with pytest.raises(auth_db.AuthDatabaseNotConfiguredError) as excinfo:
auth_db.get_auth_engine()
exc = excinfo.value
rendered = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
assert _SPECIALS_PASSWORD not in rendered
# Обломки пароля тоже не должны просочиться: пиним, что цепочка оборвана и
# рендерится ровно наше сообщение-константа.
assert "invalid literal for int" not in rendered
assert exc.__cause__ is None
assert exc.__suppress_context__ is True
assert str(exc) == auth_db._MALFORMED_DSN_MSG
def test_assembled_dsn_is_never_malformed(monkeypatch: pytest.MonkeyPatch) -> None:
"""Сборка из частей не может дать нечитаемый DSN даже на злом пароле.
Обратная сторона экранирования: путь «из частей» не должен уметь попадать в
ветку `_MALFORMED_DSN_MSG` вообще — иначе ротация пароля с неудачным
символом положила бы вход.
"""
use_identity_mode(monkeypatch, "auth")
_set_password(monkeypatch, "://@:/?#%" + _SPECIALS_PASSWORD)
engine = auth_db.get_auth_engine()
assert engine.url.password == "://@:/?#%" + _SPECIALS_PASSWORD
assert engine.url.host == "gendesign-postgres"
def test_password_is_not_rendered_by_settings_repr_or_dump(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Пароль не печатается ни в `repr(settings)`, ни в `model_dump()`.
Канал утечки, которого не видно глазами: обычное `str`-поле pydantic
выводит ДОСЛОВНО, поэтому любой будущий дамп настроек (диагностический
роут, `logger.debug("%s", settings)`, чужой обработчик ошибок) утащил бы
пароль роли `auth_app` в логи целиком. Поле объявлено `SecretStr` — здесь
пинится именно это свойство, а не факт наличия обёртки, чтобы откат к
голому `str` красил тест.
"""
fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD=_SPECIALS_PASSWORD)
assert _SPECIALS_PASSWORD not in repr(fresh)
assert _SPECIALS_PASSWORD not in str(fresh.model_dump())
# …и при этом значение достаётся: маскировка не должна ломать работу.
assert fresh.auth_db_password.get_secret_value() == _SPECIALS_PASSWORD
assert make_url(fresh.resolved_auth_database_url).password == _SPECIALS_PASSWORD