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