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
Чтобы включить 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 не встречается.
469 lines
25 KiB
Python
469 lines
25 KiB
Python
"""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
|