Merge pull request 'feat(tradein): DSN к БД auth собирается из AUTH_DB_PASSWORD, а не дублирует секрет' (#2619) from feat/auth-dsn-from-password into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
This commit is contained in:
commit
42adfe0900
4 changed files with 680 additions and 19 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# (ровно сегодняшний прод, поведение не меняется);
|
||||
|
|
|
|||
469
tradein-mvp/backend/tests/test_auth_dsn_from_parts.py
Normal file
469
tradein-mvp/backend/tests/test_auth_dsn_from_parts.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue