feat(tradein): DSN к БД auth собирается из AUTH_DB_PASSWORD, а не дублирует секрет
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 не встречается.
This commit is contained in:
bot-backend 2026-08-02 12:05:41 +03:00
parent 5d26e38fe8
commit 8db67fe4d0
4 changed files with 680 additions and 19 deletions

View file

@ -3,23 +3,33 @@
Отдельный модуль, а не ещё пара строк в `app.core.db`, ровно по одной причине:
`app.core.db` создаёт engine НА ИМПОРТЕ (`create_engine(settings.database_url)` в
теле модуля). Сделай мы так же для БД `auth` приложение начало бы падать на
старте везде, где `AUTH_DATABASE_URL` не задан, а не задан он сейчас ВЕЗДЕ: на
проде роль `auth_app` ещё без пароля, в тестах этой БД нет вовсе. Здесь engine
создаётся ЛЕНИВО, при первом реальном обращении.
старте везде, где реестр не сконфигурирован, а не сконфигурирован он сейчас
ВЕЗДЕ: на проде роль `auth_app` ещё без пароля, в тестах этой БД нет вовсе.
Здесь engine создаётся ЛЕНИВО, при первом реальном обращении.
Контракт ( после мержа прод обязан работать ТОЧНО как сейчас):
* `settings.identity_store == "tradein"` (дефолт) в этот модуль не заходит
никто: `app.services.identity_store` берёт сессию из `app.core.db`. Пустой
`AUTH_DATABASE_URL` при этом не ошибка ни на импорте, ни в рантайме; ни одно
соединение с БД `auth` не открывается.
* `settings.identity_store == "auth"` + пустой DSN первое же обращение
поднимает `AuthDatabaseNotConfiguredError` с внятным текстом. Именно
исключение, а НЕ тихий откат на tradein-таблицы и не пустой результат:
никто: `app.services.identity_store` берёт сессию из `app.core.db`. Пустая
конфигурация БД `auth` при этом не ошибка ни на импорте, ни в рантайме; ни
одно соединение с БД `auth` не открывается.
* `settings.identity_store == "auth"` + не сконфигурированный реестр первое
же обращение поднимает `AuthDatabaseNotConfiguredError` с внятным текстом.
Именно исключение, а НЕ тихий откат на tradein-таблицы и не пустой результат:
молчаливая деградация auth-пути означала бы «пользователь не найден» вместо
«конфигурация сломана», то есть массовый отказ входа под видом неверных
паролей либо, в обратную сторону, анонимный доступ.
Сам DSN этот модуль НЕ выбирает и НЕ склеивает берёт готовый у
`settings.resolved_auth_database_url` (явный `AUTH_DATABASE_URL`, иначе сборка из
`AUTH_DB_PASSWORD` + частей хоста/порта/базы/пользователя, иначе пусто).
В DSN пароль роли `auth_app`. Он не логируется и не попадает в текст
исключений НИ В ОДНОЙ ветке этого модуля: сообщения ниже константы, а ошибку
разбора URL от SQLAlchemy (её текст содержит исходную строку) мы перехватываем и
заменяем своей, обрывая цепочку `from None`, чтобы исходник не всплыл в
traceback. Добавляешь сюда `logger`/`raise ... {dsn}` не добавляй.
`create_engine` сам по себе к серверу не ходит (connection pool ленивый), так что
даже после первого обращения реальный коннект открывается только на первом
запросе но ошибку конфигурации мы обязаны отдать раньше, чем это станет
@ -33,19 +43,32 @@ from collections.abc import Iterator
from contextlib import contextmanager
from sqlalchemy import Engine, create_engine
from sqlalchemy.exc import ArgumentError
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings
class AuthDatabaseNotConfiguredError(RuntimeError):
"""`IDENTITY_STORE=auth`, но `AUTH_DATABASE_URL` пуст — идентичность негде читать."""
"""`IDENTITY_STORE=auth`, а DSN БД `auth` не задан/не разобрался."""
_NOT_CONFIGURED_MSG = (
"IDENTITY_STORE=auth, но AUTH_DATABASE_URL пуст: подключаться к общему реестру "
"людей (БД `auth`) не к чему. Задай DSN роли auth_app в .env.runtime — либо "
"верни IDENTITY_STORE=tradein (старое поведение на tradein_users/tradein_sessions)."
"IDENTITY_STORE=auth, но реестр людей (БД `auth`) не сконфигурирован: пусты и "
"AUTH_DB_PASSWORD, и AUTH_DATABASE_URL — подключаться не к чему. Задай в "
".env.runtime AUTH_DB_PASSWORD (пароль роли auth_app; остальные части DSN — "
"AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/AUTH_DB_USER — имеют прод-дефолты), "
"либо целиком AUTH_DATABASE_URL, либо верни IDENTITY_STORE=tradein (старое "
"поведение на tradein_users/tradein_sessions)."
)
# Текст для нечитаемого DSN. БЕЗ подстановки самого DSN — там пароль; исходную
# ошибку SQLAlchemy (она цитирует строку целиком) гасим `from None`.
_MALFORMED_DSN_MSG = (
"DSN БД `auth` не разобрался SQLAlchemy. Проверь AUTH_DATABASE_URL (если задан "
"явно) либо части AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/AUTH_DB_USER. Схема "
"обязана быть postgresql+psycopg:// (psycopg v3). Сам DSN сюда намеренно НЕ "
"подставлен: в нём пароль роли auth_app."
)
# Кеш engine/factory + защита от гонки: rbac_guard резолвит сессию на каждом
@ -58,11 +81,23 @@ _session_factory: sessionmaker[Session] | None = None
def _build() -> tuple[Engine, sessionmaker[Session]]:
"""Создаёт engine + session-factory по текущему DSN. Пустой DSN → явная ошибка."""
dsn = settings.auth_database_url.strip()
"""Создаёт engine + session-factory по текущему DSN. Нет DSN → явная ошибка.
DSN резолвит `settings` (явный AUTH_DATABASE_URL или сборка из AUTH_DB_*)
здесь только «пусто или нет» и создание engine.
"""
dsn = settings.resolved_auth_database_url
if not dsn:
raise AuthDatabaseNotConfiguredError(_NOT_CONFIGURED_MSG)
engine = create_engine(dsn, pool_pre_ping=True, future=True)
try:
engine = create_engine(dsn, pool_pre_ping=True, future=True)
except (ArgumentError, ValueError):
# ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до
# `int(port)` и падает с `invalid literal for int() with base 10: 'w'`,
# где 'w' — КУСОК ПАРОЛЯ, съехавший на позицию порта. `from None`
# обязателен: он гасит цепочку, иначе исходная ошибка (а с ней и этот
# кусок) печатается в traceback как «During handling of...».
raise AuthDatabaseNotConfiguredError(_MALFORMED_DSN_MSG) from None
factory = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
return engine, factory
@ -81,7 +116,8 @@ def get_auth_engine() -> Engine:
"""Engine БД `auth` (создаётся при первом вызове).
Raises:
AuthDatabaseNotConfiguredError: `AUTH_DATABASE_URL` пуст.
AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни
AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался.
"""
engine, _ = _ensure_built()
return engine
@ -91,7 +127,8 @@ def get_auth_session_factory() -> sessionmaker[Session]:
"""Session-factory БД `auth` (создаётся при первом вызове).
Raises:
AuthDatabaseNotConfiguredError: `AUTH_DATABASE_URL` пуст.
AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни
AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался.
"""
_, factory = _ensure_built()
return factory

View file

@ -1,10 +1,35 @@
"""Минимальный settings для standalone trade-in MVP."""
from typing import Literal
from urllib.parse import quote
from pydantic import Field
from pydantic import Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# ── Дефолтные части DSN БД `auth` (общий реестр людей, эпик «единый вход») ──────
# Вынесены константами, потому что используются ДВАЖДЫ: как `Field(default=...)`
# и как запасное значение, если переменная окружения задана пустой строкой
# (`AUTH_DB_HOST=` в .env.runtime не должен давать DSN вида `...@:5432/auth`).
#
# ⚠️ ХОСТ — главная ловушка. Внутри стека «Меры» имя `postgres` резолвится в ЕЁ
# СОБСТВЕННЫЙ контейнер: tradein-mvp/docker-compose.prod.yml объявляет сервис
# `postgres` (container_name `tradein-postgres`, сети `tradein-net` +
# `gendesign_shared`) и собирает им продуктовый DATABASE_URL —
# `postgresql+psycopg://...@postgres:5432/tradein`. БД `auth` живёт НЕ там, а на
# постгресе главного стека: корневой docker-compose.prod.yml вешает своему
# сервису `postgres` в сети `shared` (external, name `gendesign_shared`) алиас
# `gendesign-postgres`. tradein-backend к `gendesign_shared` подписан, поэтому
# `gendesign-postgres:5432` из него резолвится, а `postgres:5432` увело бы в
# чужую (свою же продуктовую) БД — там ни роли auth_app, ни таблиц реестра.
# Порт 5432 — ВНУТРИСЕТЕВОЙ порт контейнера; публикация `127.0.0.1:5432:5432` в
# корневом compose существует только ради SSH-туннеля с хоста и к этому пути
# отношения не имеет.
_AUTH_DB_DEFAULT_HOST = "gendesign-postgres"
_AUTH_DB_DEFAULT_PORT = 5432
_AUTH_DB_DEFAULT_NAME = "auth"
# Роль приложения из data/sql/auth/002_auth_app_role.sql (least privilege).
_AUTH_DB_DEFAULT_USER = "auth_app"
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@ -81,7 +106,127 @@ class Settings(BaseSettings):
# к этой БД не обращается ни одна строка кода: engine не создаётся,
# соединение не открывается, пустой DSN на старте ничего не роняет — см.
# app.core.auth_db (ленивое создание engine). ENV: AUTH_DATABASE_URL.
#
# Задавать его РУКАМИ больше не обязательно — см. `resolved_auth_database_url`
# ниже: при пустом AUTH_DATABASE_URL и заданном AUTH_DB_PASSWORD DSN собирается
# из частей. Явное значение, если оно есть, по-прежнему выигрывает.
auth_database_url: str = Field(default="", validation_alias="AUTH_DATABASE_URL")
# ── Части DSN БД `auth` — чтобы пароль жил в ОДНОМ месте ────────────────────
# Пароль роли auth_app уже лежит в .env.runtime отдельной переменной
# AUTH_DB_PASSWORD: её читает .forgejo/workflows/deploy.yml, чтобы выполнить
# ALTER ROLE (ops/db-bootstrap/set_auth_app_password.sql). Требовать вдобавок
# целиковый AUTH_DATABASE_URL значило бы держать ОДИН секрет в ДВУХ местах:
# сменили пароль роли, забыли переписать DSN — и вход ложится молча и целиком
# (аутентификация к БД `auth` отваливается для всех сразу).
#
# ⚠️ ops-нюанс: deploy.yml делает ALTER ROLE, читая AUTH_DB_PASSWORD из
# backend/.env.runtime ГЛАВНОГО стека, а этот контейнер читает
# tradein-mvp/backend/.env.runtime (env_file в tradein-mvp/docker-compose.prod.yml).
# Файлы разные — переменная должна быть в обоих. Зато их значение сравнимо
# глазами, чего нельзя сказать про пароль, замурованный внутрь DSN.
#
# Пусто по умолчанию — как и AUTH_DATABASE_URL: в дефолтном режиме
# IDENTITY_STORE=tradein ничего из этого не читается. ENV: AUTH_DB_PASSWORD.
#
# SecretStr, а не str: это единственное поле-секрет, добавленное здесь, и
# обёртка бесплатно закрывает канал утечки, которого не видно глазами —
# `repr(settings)` и `settings.model_dump()` печатают обычные str-поля
# ДОСЛОВНО. Сегодня их никто не рендерит (grep по app: ни дампа env, ни
# `/debug`; sentry_sdk в app/main.py идёт с include_local_variables=False),
# но появиться такой рендер может в любой момент и тихо — с SecretStr он
# напечатает `SecretStr('**********')`. Значение достаётся ровно в одном
# месте — `.get_secret_value()` в резолвере ниже.
# ⚠️ Соседние секреты (database_url, telegram_bot_token, …) остались str —
# это предсуществующее положение, а не «здесь безопасно, а там нет».
auth_db_password: SecretStr = Field(default=SecretStr(""), validation_alias="AUTH_DB_PASSWORD")
# Остальные части — с дефолтами, верными для прод-стека (см. константы выше).
# Переопределяются через ENV для dev/локального запуска (напр. AUTH_DB_HOST=
# localhost + AUTH_DB_PORT=15432 поверх SSH-туннеля).
# ENV: AUTH_DB_HOST, AUTH_DB_PORT, AUTH_DB_NAME, AUTH_DB_USER.
auth_db_host: str = Field(default=_AUTH_DB_DEFAULT_HOST, validation_alias="AUTH_DB_HOST")
auth_db_port: int = Field(default=_AUTH_DB_DEFAULT_PORT, validation_alias="AUTH_DB_PORT")
auth_db_name: str = Field(default=_AUTH_DB_DEFAULT_NAME, validation_alias="AUTH_DB_NAME")
auth_db_user: str = Field(default=_AUTH_DB_DEFAULT_USER, validation_alias="AUTH_DB_USER")
@field_validator("auth_db_port", mode="before")
@classmethod
def _blank_port_means_default(cls, value: object) -> object:
"""`AUTH_DB_PORT=` (пустая строка) → прод-дефолт, а не падение на импорте.
Симметрия с host/name/user, у которых пустое значение переменной падает
обратно на дефолт в резолвере. Для порта того же добиться нельзя: он
типизирован `int` и валидируется pydantic'ом ДО всякой нашей логики, а
`settings = Settings()` выполняется на уровне модуля то есть
`AUTH_DB_PORT=` в .env.runtime роняло бы ValidationError на импорте
конфига и уводило контейнер в restart-loop. Причём В ЛЮБОМ режиме,
включая дефолтный IDENTITY_STORE=tradein, где к БД `auth` не идёт ни
одного обращения ровно тот инвариант «дефолт не трогаем», который
держит остальной код.
Сценарий не гипотетический: ops копирует блок AUTH_DB_* в .env.runtime и
заполняет только пароль остальные строки остаются пустыми намеренно.
`mode="before"` потому что вмешаться надо ДО приведения к int.
Непустой мусор (`AUTH_DB_PORT=abc`) по-прежнему валится, и правильно:
это опечатка со смыслом, а не «оставил пустым».
"""
if isinstance(value, str) and not value.strip():
return _AUTH_DB_DEFAULT_PORT
return value
@property
def resolved_auth_database_url(self) -> str:
"""DSN БД `auth` — единственный источник правды для `app.core.auth_db`.
Приоритет:
1. `AUTH_DATABASE_URL`, если задан выигрывает всегда. Обратная
совместимость (так настроено «до») плюс аварийный обход: если DSN
понадобился нестандартный (другой хост, sslmode, пул-байпас), его
можно вписать целиком, не трогая код.
2. Иначе, если задан `AUTH_DB_PASSWORD` DSN собирается из частей.
3. Иначе пустая строка, то есть «не сконфигурировано». Это НЕ ошибка
сама по себе: при `IDENTITY_STORE=tradein` (дефолт) сюда не заходит
никто. Ошибку явную, а не тихий фолбэк поднимает `app.core.auth_db`
и только когда реестр реально понадобился.
Возвращаемое значение СОДЕРЖИТ ПАРОЛЬ: не логировать, не класть в текст
исключений, не отдавать наружу (`/health`, `/debug`, метрики).
Пароль экранируется `quote(..., safe="")`: спецсимвол (`@`, `:`, `/`, `?`,
`#`, `%`) внутри пароля иначе порвал бы URL по своей грамматике — `@`
сдвинул бы границу host, `/` открыл бы path. Разбор дал бы либо ошибку,
либо, что хуже, МОЛЧА другой хост/базу. По той же причине экранируется
имя пользователя.
А вот имя БД и хост НЕ экранируются, и это не забывчивость: SQLAlchemy
раскодирует обратно только userinfo (user/password), а path отдаёт как
есть. Прогони мы имя БД через `quote`, в сервер уехало бы литеральное
`c%2Fd` вместо `c/d` (проверено round-trip'ом в тестах). Хосту
%-кодирование тоже только мешает оно поломало бы IPv6-скобки.
"""
explicit = self.auth_database_url.strip()
if explicit:
return explicit
# `.strip()` только для ПРОВЕРКИ «задан ли»: пробельная строка в .env — это
# опечатка, а не пароль. В сам DSN идёт значение КАК ЕСТЬ (не стриппится):
# ведущий/хвостовой пробел может быть частью настоящего пароля.
# Единственная точка распаковки SecretStr во всём коде — см. поле выше.
password = self.auth_db_password.get_secret_value()
if not password.strip():
return ""
user = quote(self.auth_db_user.strip() or _AUTH_DB_DEFAULT_USER, safe="")
secret = quote(password, safe="")
host = self.auth_db_host.strip() or _AUTH_DB_DEFAULT_HOST
port = self.auth_db_port
name = self.auth_db_name.strip() or _AUTH_DB_DEFAULT_NAME
# Схема — ровно та же, что у продуктового DATABASE_URL (psycopg v3;
# `postgresql://` без суффикса увёл бы SQLAlchemy на psycopg2, которого в
# зависимостях нет).
return f"postgresql+psycopg://{user}:{secret}@{host}:{port}/{name}"
# Где живут identity (люди + сессии):
# "tradein" (ДЕФОЛТ) — БД tradein, таблицы tradein_users/tradein_sessions
# (ровно сегодняшний прод, поведение не меняется);

View file

@ -0,0 +1,469 @@
"""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

View file

@ -33,6 +33,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from pydantic import SecretStr
from sqlalchemy import Engine
from app.core import auth_db, config
@ -63,6 +64,15 @@ def _clean_identity_state(monkeypatch: pytest.MonkeyPatch):
auth_db.reset_auth_db()
monkeypatch.setattr(config.settings, "identity_store", "tradein")
monkeypatch.setattr(config.settings, "auth_database_url", "")
# Второй источник DSN: при пустом AUTH_DATABASE_URL он собирается из
# AUTH_DB_PASSWORD + частей (см. Settings.resolved_auth_database_url). Не
# обнули его здесь — и заданная в окружении переменная сделала бы реестр
# «сконфигурированным»: тесты про «пустой DSN → явная ошибка» позеленели бы
# мимо проверяемого поведения.
# SecretStr, а не "": поле объявлено `SecretStr`, а `validate_assignment` у
# Settings выключен — monkeypatch кладёт значение КАК ЕСТЬ, без приведения
# типа, и голая строка уронила бы резолвер на `.get_secret_value()`.
monkeypatch.setattr(config.settings, "auth_db_password", SecretStr(""))
yield
auth_db.reset_auth_db()