gendesign/tradein-mvp/backend/tests/support/identity_modes.py
bot-backend eccb895db1
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
feat(tradein): переключаемый реестр людей — подготовка переезда «Меры» в БД auth [PR-2b/6]
Дефолт не меняет ничего: 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, тот же красный).
2026-08-01 02:50:14 +03:00

200 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Помощники для тестов, зависящих от того, В КАКОМ РЕЕСТРЕ живут люди.
Эпик «единый вход»: `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)