All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
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 / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 2m40s
Дефолт не меняет ничего: IDENTITY_STORE="tradein" — это сегодняшний прод, tradein_users/tradein_sessions, соединение с БД auth не открывается вообще. Переключение делается одной переменной окружения ПОСЛЕ того, как на проде появится пароль auth_app и будут скопированы данные. Так сделано намеренно: мерж, который зависит от невыполненного ручного шага, — это мерж, который ломает прод в момент невнимательности. Ядро. app/services/identity_store.py — единственное место, знающее, в какой БД и в каких таблицах живёт реестр. Имена таблиц берутся из фиксированного словаря по значению флага, не конкатенацией с вводом. app/core/auth_db.py — ЛЕНИВЫЙ engine БД auth (core/db.py создаёт свой на импорте; такое же для auth роняло бы старт без DSN). Одно понятие состояния доступа вместо двух. В tradein_users состояние — булев is_active, в auth.users — access_state из трёх значений. Конверсия живёт в одной функции to_access_state(): True→active, False→disabled, а неизвестная строка, NULL или чужой тип → disabled с WARNING. Fail-closed выбран сознательно: если следующая миграция добавит четвёртое состояние, оно по умолчанию НЕ будет пускать. Проверка доступа — свойство can_sign_in, а не сравнение со строкой. Логин в режиме auth. Пароль проверяется ВСЕГДА и ДО ветвления по состоянию — иначе появляется timing-oracle и перечисление логинов. Верный пароль + trial_expired → 403 с машиночитаемым code="access_expired", сессия НЕ создаётся. Верный пароль + disabled → тот же generic 401, что и при неверном пароле. Резолв уже выданной сессии пропускает только active — блокировка обрывает сессию немедленно, а не по истечении sliding-refresh. Старт падает явно, если IDENTITY_STORE=auth, а DSN не задан. Без этого ошибка конфигурации не похожа на аварию: продуктовая БД жива, приложение работает, а rbac_guard ловит исключение резолва вместе с любым другим сбоем и падает в legacy trusted-header ветку — то есть сутками раздаёт права из roles.yaml мимо реестра, включая аккаунты с disabled. Форма входа понимает новый код ответа. Ветвление по detail.code, а не по тексту: текст бэк вправе менять, код — нет. Гранты соблюдены, а не обойдены: auth_app не имеет UPDATE на role/manager_id и не имеет DELETE на users (миграция 004, column-level). Тесты: 2996 passed (+59). Единственный красный — test_search_cache_hit — предсуществующий: проверен контрольным полным прогоном на чистом main (2937 passed, тот же красный).
200 lines
12 KiB
Python
200 lines
12 KiB
Python
"""Помощники для тестов, зависящих от того, В КАКОМ РЕЕСТРЕ живут люди.
|
||
|
||
Эпик «единый вход»: `settings.identity_store` переключает код «Меры» между
|
||
`tradein_users`/`tradein_sessions` (БД tradein — ДЕФОЛТ, сегодняшнее поведение
|
||
прода) и `users`/`sessions` (БД `auth`). Различаются имена таблиц И тип колонки
|
||
состояния доступа (`is_active boolean` против `access_state text`).
|
||
|
||
⚠️ ЗАЧЕМ ЭТОТ МОДУЛЬ (главная ловушка этих тестов). Интеграционные тесты
|
||
`test_auth_api.py` / `test_team_api.py` используют fake-DB, который диспатчит по
|
||
ТЕКСТУ SQL. Если ветка такого fake'а сравнивает с литералом «tradein_users», то
|
||
при `identity_store="auth"` она просто перестаёт матчиться — fake вернёт пустой
|
||
результат вместо строки, а тест останется ЗЕЛЁНЫМ на сломанном коде. Поэтому:
|
||
|
||
* имена для матчинга берутся из `identity_schema()` (`sql_names()` ниже) —
|
||
ровно оттуда же, откуда их берёт продакшн-код;
|
||
* непонятый SQL в fake'ах ОБЯЗАН падать `AssertionError`, а не возвращать
|
||
пустоту (см. `raise AssertionError(f"unhandled fake SQL ...")` в обоих
|
||
файлах) — это то, что превращает «ветка отвалилась» в красный тест.
|
||
|
||
`column_value()` — намеренно ЛИТЕРАЛЬНАЯ таблица «состояние → значение
|
||
колонки», а НЕ вызов `identity_store.access_state_param()`. Fake обязан хранить
|
||
то, что реально лежало бы в Postgres; если бы он звал ту же production-функцию,
|
||
что и проверяемый код, её инверсия (`active` ↔ `disabled`) прошла бы round-trip
|
||
через fake незамеченной, и тест бы не покраснел.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from collections.abc import Callable, Iterator
|
||
from contextlib import contextmanager
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
from app.core import auth_db, config
|
||
from app.services import identity_store
|
||
from app.services.identity_store import AccessState, identity_schema
|
||
|
||
# Оба допустимых значения `IDENTITY_STORE` (Literal в pydantic-настройках).
|
||
# "tradein" ПЕРВЫЙ — это дефолт и путь прода; при чтении вывода pytest'а первый
|
||
# параметр всегда «как сейчас», второй — «после переезда».
|
||
IDENTITY_MODES = ("tradein", "auth")
|
||
|
||
# Состояние доступа → значение, которое реально лежит в колонке реестра.
|
||
# Литералы, независимые от production-кода (см. модульный docstring).
|
||
# `trial_expired` в булевой схеме ОТСУТСТВУЕТ: состояния «пробный период истёк»
|
||
# там не существовало, выразить его нечем — тесты про него имеют смысл только в
|
||
# режиме `auth`, поэтому здесь явная ошибка вместо тихого приведения к False.
|
||
_COLUMN_VALUE: dict[tuple[str, AccessState], bool | str] = {
|
||
("tradein", AccessState.ACTIVE): True,
|
||
("tradein", AccessState.DISABLED): False,
|
||
("auth", AccessState.ACTIVE): "active",
|
||
("auth", AccessState.TRIAL_EXPIRED): "trial_expired",
|
||
("auth", AccessState.DISABLED): "disabled",
|
||
}
|
||
|
||
|
||
def column_value(state: AccessState) -> bool | str:
|
||
"""Значение состояния *state* в колонке реестра для ТЕКУЩЕГО режима."""
|
||
store = config.settings.identity_store
|
||
try:
|
||
return _COLUMN_VALUE[(store, state)]
|
||
except KeyError:
|
||
raise AssertionError(
|
||
f"состояние {state.value!r} не существует в схеме {store!r} — "
|
||
f"такой тест имеет смысл только при identity_store='auth'"
|
||
) from None
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class SqlNames:
|
||
"""Имена, по которым fake-DB узнаёт запрос в ТЕКУЩЕМ режиме."""
|
||
|
||
users: str
|
||
sessions: str
|
||
access_state_column: str
|
||
access_state_sql_type: str
|
||
|
||
|
||
def sql_names() -> SqlNames:
|
||
"""Имена таблиц/колонки из `identity_schema()` — источник тот же, что у кода."""
|
||
schema = identity_schema()
|
||
return SqlNames(
|
||
users=schema.users_table,
|
||
sessions=schema.sessions_table,
|
||
access_state_column=schema.access_state_column,
|
||
access_state_sql_type=schema.access_state_sql_type,
|
||
)
|
||
|
||
|
||
def assert_reads_access_state(sql: str, names: SqlNames) -> None:
|
||
"""Запрос, читающий состояние доступа, ОБЯЗАН брать колонку ТЕКУЩЕГО режима.
|
||
|
||
Ставится в те ветки fake-DB, которые отдают строку человека. Без неё fake
|
||
остаётся ЗЕЛЁНЫМ на захардкоженном `is_active AS access_state`: строку он
|
||
собирает из `_Store`, где ключ УЖЕ называется `access_state`, и про имя
|
||
колонки в SELECT'е ничего не знает — то есть запрос, невозможный на реальном
|
||
Postgres (`column "is_active" does not exist` в БД `auth`), проехал бы молча.
|
||
|
||
Измерено мутацией: захардкодить колонку в `team._employee_columns` — без
|
||
этой проверки все 48 тестов «Команды» остаются зелёными; с ней ветка
|
||
перестаёт матчиться, SQL доезжает до `raise AssertionError` в конце
|
||
`execute` и тесты краснеют.
|
||
|
||
Алиас проверяется отдельно от имени колонки: без `AS access_state`
|
||
вызывающий код читал бы то `is_active`, то `access_state`, то есть завёл бы
|
||
второе представление состояния — ровно то, чего эпик не допускает.
|
||
"""
|
||
expected = f"{names.access_state_column} AS access_state"
|
||
if expected not in sql:
|
||
raise AssertionError(
|
||
f"запрос к реестру не читает колонку состояния текущего режима "
|
||
f"({expected!r}): {sql!r}"
|
||
)
|
||
|
||
|
||
def assert_insert_writes_access_state(sql: str, names: SqlNames) -> None:
|
||
"""INSERT в реестр обязан перечислять колонку состояния ТЕКУЩЕГО режима.
|
||
|
||
Проверяется именно СПИСОК КОЛОНОК, а не наличие подстроки: bind-параметр
|
||
называется `:access_state` в обоих режимах, поэтому `... , :access_state)`
|
||
в VALUES матчился бы всегда — и `INSERT INTO users (..., is_active)`
|
||
(невозможный в БД `auth`) проехал бы молча. Измерено мутацией.
|
||
"""
|
||
match = re.search(rf"INSERT INTO\s+{re.escape(names.users)}\s*\(([^)]*)\)", sql)
|
||
if match is None:
|
||
raise AssertionError(f"не разобрал список колонок INSERT'а в реестр: {sql!r}")
|
||
columns = {c.strip() for c in match.group(1).split(",")}
|
||
if names.access_state_column not in columns:
|
||
raise AssertionError(
|
||
f"INSERT в реестр не пишет колонку состояния текущего режима "
|
||
f"({names.access_state_column!r}); в списке: {sorted(columns)}"
|
||
)
|
||
|
||
|
||
def assert_update_writes_access_state(sql: str, names: SqlNames) -> None:
|
||
"""UPDATE реестра обязан присваивать колонку состояния ТЕКУЩЕГО режима — и
|
||
кастовать параметр в ЕЁ тип.
|
||
|
||
CAST здесь несущий: параметр может быть NULL («поле не пришло в PATCH» →
|
||
`COALESCE(CAST(:x AS T), col)`), и без явного типа Postgres тип NULL-параметра
|
||
не выведет. Захардкоженный `boolean` в текстовой схеме — ошибка уровня БД,
|
||
которую fake иначе не увидел бы.
|
||
"""
|
||
assignment = f"{names.access_state_column} = COALESCE("
|
||
if assignment not in sql:
|
||
raise AssertionError(
|
||
f"UPDATE реестра не присваивает колонку состояния текущего режима "
|
||
f"({assignment!r}): {sql!r}"
|
||
)
|
||
cast = f"CAST(:access_state AS {names.access_state_sql_type})"
|
||
if cast not in sql:
|
||
raise AssertionError(
|
||
f"UPDATE реестра кастует состояние не в тип текущей схемы ({cast!r}): {sql!r}"
|
||
)
|
||
|
||
|
||
def use_identity_mode(monkeypatch: pytest.MonkeyPatch, mode: str) -> str:
|
||
"""Переключает реестр на *mode* на время теста.
|
||
|
||
`reset_auth_db()` — на случай, если предыдущий тест успел построить engine
|
||
БД `auth`: закешированный engine пережил бы monkeypatch настроек (он живёт в
|
||
module-global, а не в `settings`) и утёк бы сюда.
|
||
"""
|
||
auth_db.reset_auth_db()
|
||
monkeypatch.setattr(config.settings, "identity_store", mode)
|
||
return mode
|
||
|
||
|
||
def patch_identity_sessions(monkeypatch: pytest.MonkeyPatch, make_db: Callable[[], Any]) -> None:
|
||
"""Подменяет ОБА источника сессии реестра так, чтобы работал РЕАЛЬНЫЙ
|
||
`identity_store.identity_session()` / `get_identity_db()`, а не их копия
|
||
в тесте.
|
||
|
||
Точки подмены выбраны настолько «низко», насколько возможно:
|
||
* `identity_store.SessionLocal` — то, что открывает `identity_session()`
|
||
в режиме "tradein" (импортирован по имени, поэтому патчим в
|
||
`identity_store`, а не в `app.core.db`);
|
||
* `auth_db.auth_session` — то, что открывают `identity_session()` и
|
||
`get_identity_db()` в режиме "auth" (`identity_store` держит ссылку на
|
||
МОДУЛЬ `auth_db`, поэтому подмена атрибута модуля видна ему сразу).
|
||
|
||
Благодаря этому ветвление по режиму остаётся на production-коде: тест не
|
||
повторяет его у себя, и регрессия в `get_identity_db` (например, если он
|
||
перестанет отдавать в режиме "tradein" тот же объект `Session`, что и
|
||
`get_db`) не сможет спрятаться за тестовым дублёром.
|
||
|
||
*make_db* вызывается БЕЗ аргументов и обязан отдавать новый fake-Session,
|
||
поддерживающий `with ... as db` (как настоящая `Session`).
|
||
"""
|
||
|
||
@contextmanager
|
||
def _fake_auth_session() -> Iterator[Any]:
|
||
with make_db() as db:
|
||
yield db
|
||
|
||
monkeypatch.setattr(identity_store, "SessionLocal", make_db)
|
||
monkeypatch.setattr(auth_db, "auth_session", _fake_auth_session)
|