gendesign/tradein-mvp/backend/tests/test_auth_session.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

439 lines
18 KiB
Python
Raw 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.

"""Tests for app.services.auth_session — session CRUD + DB-role scope mapping (#2552).
Coverage:
- create_session: INSERT with CAST(...) (never `:x::type`), commit, unique tokens.
- get_session_user: valid/expired/не-active/missing-row + sliding refresh (only when
last_seen_at is stale, best-effort — a refresh failure still returns the user).
- get_user_by_username: found/not-found + состояние доступа как `AccessState`.
- revoke_session / revoke_user_sessions: DELETE + commit.
- get_db_role_scope: employee/manager/admin/unknown mapping.
All functions here take `db: Session` as a plain argument (no SessionLocal() opened
internally) — unit tests just pass a hand-rolled fake, mirroring the `_FakeSession`
pattern from tests/test_user_events.py but adapted for `.fetchone()`-based reads.
⚠️ ОБА РЕЖИМА РЕЕСТРА. Эпик «единый вход» вынес имена таблиц и имя/тип колонки
состояния доступа в `identity_store.identity_schema()`. Тесты, которые вообще
трогают SQL, прогоняются в ОБОИХ режимах (фикстура `identity_mode`): "tradein"
(дефолт, сегодняшний прод — `tradein_users`/`tradein_sessions`, boolean
`is_active`) и "auth" (`users`/`sessions`, text `access_state`). Ожидаемые имена
в ассертах берутся из `identity_schema()` — из того же словаря, что и у кода,
поэтому переименование таблиц не «разъезжает» тест с реальностью тихо;
поломка запроса ловится тем, что fake отдаёт строку ТОЛЬКО на ожидаемый SQL,
а сам SQL проверяется явными ассертами ниже.
Тесты БЕЗ фикстуры `identity_mode` намеренно идут в дефолтном режиме
(`_default_identity_mode` autouse) — это чистая логика без SQL.
"""
from __future__ import annotations
import os
import re
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from app.services import auth_session as svc
from app.services.identity_store import AccessState, identity_schema
from tests.support.identity_modes import IDENTITY_MODES, column_value, use_identity_mode
# ---------------------------------------------------------------------------
# Режим реестра
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _default_identity_mode(monkeypatch: pytest.MonkeyPatch) -> None:
"""Каждый тест стартует в ДЕФОЛТНОМ режиме, даже если предыдущий его менял."""
use_identity_mode(monkeypatch, "tradein")
@pytest.fixture(params=IDENTITY_MODES)
def identity_mode(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> str:
"""Тест прогоняется дважды: "tradein" (прод) и "auth" (после переезда)."""
return use_identity_mode(monkeypatch, request.param)
@pytest.fixture
def auth_mode(monkeypatch: pytest.MonkeyPatch) -> str:
"""Только режим "auth" — для состояний, невыразимых булевой колонкой."""
return use_identity_mode(monkeypatch, "auth")
# ---------------------------------------------------------------------------
# Fake DB session
# ---------------------------------------------------------------------------
class _FakeDB:
"""Minimal `Session` stand-in: queued `.fetchone()` results per `execute()` call,
in call order. `execute()` beyond the queue returns a result with `fetchone()
-> None`. Set `.raise_on_call = N` to make the Nth `execute()` (1-indexed) raise.
"""
def __init__(self, rows: list[Any] | None = None) -> None:
self._rows: list[Any] = list(rows or [])
self.executed: list[tuple[str, dict[str, Any] | None]] = []
self.committed = 0
self.rolled_back = 0
self.raise_on_call: int | None = None
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> SimpleNamespace:
call_no = len(self.executed) + 1
self.executed.append((str(stmt), params))
if self.raise_on_call == call_no:
raise RuntimeError("simulated DB failure")
row = self._rows.pop(0) if self._rows else None
return SimpleNamespace(fetchone=lambda: row)
def commit(self) -> None:
self.committed += 1
def rollback(self) -> None:
self.rolled_back += 1
# Часовой «аргумент не передан» — None здесь занят (это валидное сырое значение
# колонки: NULL, который to_access_state обязан трактовать как disabled).
_MISSING = object()
def _session_row(
*,
user_id: int = 1,
expires_at: datetime | None = None,
last_seen_at: datetime | None = None,
username: str = "alice",
role: str = "employee",
access_state: AccessState = AccessState.ACTIVE,
raw_access_state: object = _MISSING,
) -> SimpleNamespace:
"""Строка JOIN'а sessions×users, как её отдал бы драйвер.
Колонка состояния всегда приезжает под алиасом `access_state` (`AS access_state`
в реальном SELECT'е), а ЗНАЧЕНИЕ в ней — то, что лежит в БД текущего режима:
boolean для `tradein_users.is_active`, text для `auth.users.access_state`.
*raw_access_state* — обход таблицы состояний для проверки fail-closed на
значении, которого код не знает.
"""
now = datetime.now(UTC)
return SimpleNamespace(
user_id=user_id,
expires_at=expires_at if expires_at is not None else now + timedelta(hours=1),
last_seen_at=last_seen_at if last_seen_at is not None else now,
username=username,
role=role,
display_name="Alice A.",
org_name="Org LLC",
email="alice@example.com",
access_state=(
column_value(access_state) if raw_access_state is _MISSING else raw_access_state
),
)
def _user_row(
*,
user_id: int = 1,
username: str = "alice",
password_hash: str | None = "hash",
role: str = "employee",
access_state: AccessState = AccessState.ACTIVE,
) -> SimpleNamespace:
return SimpleNamespace(
id=user_id,
username=username,
password_hash=password_hash,
role=role,
access_state=column_value(access_state),
display_name="Alice A.",
org_name="Org LLC",
email="alice@example.com",
)
# ---------------------------------------------------------------------------
# create_session
# ---------------------------------------------------------------------------
def test_create_session_inserts_and_commits(identity_mode: str) -> None:
db = _FakeDB()
token = svc.create_session(db, user_id=42, ip="1.2.3.4", user_agent="pytest")
assert db.committed == 1
assert len(db.executed) == 1
sql, params = db.executed[0]
assert f"INSERT INTO {identity_schema().sessions_table}" in sql
assert params is not None
assert params["user_id"] == 42
assert params["ip"] == "1.2.3.4"
assert params["user_agent"] == "pytest"
assert params["token"] == token
assert isinstance(token, str)
assert len(token) >= 32
def test_create_session_cast_not_doublecolon(identity_mode: str) -> None:
db = _FakeDB()
svc.create_session(db, user_id=1)
sql, _ = db.executed[0]
assert not re.search(r":\w+::\w", sql)
assert "CAST(:ttl_hours AS integer)" in sql
assert "CAST(:ip AS inet)" in sql
def test_create_session_tokens_are_unique() -> None:
db = _FakeDB()
t1 = svc.create_session(db, user_id=1)
t2 = svc.create_session(db, user_id=1)
assert t1 != t2
# ---------------------------------------------------------------------------
# get_session_user
# ---------------------------------------------------------------------------
def test_get_session_user_no_token_returns_none() -> None:
db = _FakeDB()
assert svc.get_session_user(db, "") is None
assert db.executed == []
def test_get_session_user_missing_row_returns_none(identity_mode: str) -> None:
schema = identity_schema()
db = _FakeDB(rows=[None])
assert svc.get_session_user(db, "tok") is None
sql, params = db.executed[0]
assert f"FROM {schema.sessions_table} s" in sql
assert f"JOIN {schema.users_table} u" in sql
# Колонка состояния — под именем текущей схемы и обязательно с алиасом:
# без него вызывающий код читал бы то `is_active`, то `access_state`.
assert f"u.{schema.access_state_column} AS access_state" in sql
assert params == {"token": "tok"}
def test_get_session_user_expired_returns_none(identity_mode: str) -> None:
now = datetime.now(UTC)
db = _FakeDB(rows=[_session_row(expires_at=now - timedelta(minutes=1))])
assert svc.get_session_user(db, "tok") is None
# Никакого sliding-refresh UPDATE для невалидной сессии.
assert len(db.executed) == 1
def test_get_session_user_disabled_returns_none(identity_mode: str) -> None:
"""Жёстко заблокированный аккаунт — сессия недействительна в обеих схемах."""
db = _FakeDB(rows=[_session_row(access_state=AccessState.DISABLED)])
assert svc.get_session_user(db, "tok") is None
assert len(db.executed) == 1
def test_get_session_user_trial_expired_returns_none(auth_mode: str) -> None:
"""Пробный период истёк — УЖЕ ВЫДАННАЯ сессия гасится немедленно.
Иначе сотрудник, залогиненный до истечения пробного доступа, продолжал бы
работать, а sliding-refresh продлевал бы ему `expires_at` бесконечно —
состояние `trial_expired` не наступило бы для него никогда.
"""
db = _FakeDB(rows=[_session_row(access_state=AccessState.TRIAL_EXPIRED)])
assert svc.get_session_user(db, "tok") is None
# Ни UPDATE (sliding refresh), ни commit — сессия не продлевается.
assert len(db.executed) == 1
assert db.committed == 0
def test_get_session_user_unknown_state_returns_none(auth_mode: str) -> None:
"""Fail-closed: состояние, которого код не знает (миграция впереди кода),
НЕ пускает. Обратный выбор молча раздавал бы доступ по новому значению."""
db = _FakeDB(rows=[_session_row(raw_access_state="pending_review")])
assert svc.get_session_user(db, "tok") is None
assert len(db.executed) == 1
def test_get_session_user_valid_recent_no_refresh(identity_mode: str) -> None:
"""last_seen_at свежий (<5 мин) — sliding refresh НЕ триггерится."""
now = datetime.now(UTC)
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=1))])
result = svc.get_session_user(db, "tok")
assert result is not None
assert result["username"] == "alice"
assert result["role"] == "employee"
assert result["display_name"] == "Alice A."
assert result["org_name"] == "Org LLC"
assert result["email"] == "alice@example.com"
assert result["user_id"] == 1
# Состояние доступа приезжает ЕДИНЫМ понятием, а не boolean/str по режимам;
# сюда доходит только ACTIVE (не-active отсеян выше).
assert result["access_state"] is AccessState.ACTIVE
# Только 1 execute (SELECT) — никакого UPDATE.
assert len(db.executed) == 1
assert db.committed == 0
def test_get_session_user_stale_last_seen_triggers_refresh(identity_mode: str) -> None:
"""last_seen_at старше 5 минут — один UPDATE (sliding refresh) + commit."""
now = datetime.now(UTC)
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=10))])
result = svc.get_session_user(db, "tok")
assert result is not None
assert len(db.executed) == 2
update_sql, update_params = db.executed[1]
assert f"UPDATE {identity_schema().sessions_table}" in update_sql
assert "SET last_seen_at" in update_sql
assert not re.search(r":\w+::\w", update_sql)
assert "CAST(:ttl_hours AS integer)" in update_sql
assert update_params == {"ttl_hours": 720, "token": "tok"}
assert db.committed == 1
def test_get_session_user_refresh_failure_is_swallowed(identity_mode: str) -> None:
"""Sliding-refresh UPDATE падает — всё равно возвращаем валидного юзера
(best-effort refresh, не часть решения "валидна ли сессия")."""
now = datetime.now(UTC)
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=10))])
db.raise_on_call = 2
result = svc.get_session_user(db, "tok")
assert result is not None
assert result["username"] == "alice"
assert db.rolled_back == 1
assert db.committed == 0
# ---------------------------------------------------------------------------
# get_user_by_username
# ---------------------------------------------------------------------------
def test_get_user_by_username_found(identity_mode: str) -> None:
schema = identity_schema()
db = _FakeDB(rows=[_user_row()])
user = svc.get_user_by_username(db, "alice")
assert user is not None
assert user["username"] == "alice"
assert user["password_hash"] == "hash"
assert user["role"] == "employee"
assert user["access_state"] is AccessState.ACTIVE
sql, params = db.executed[0]
assert f"FROM {schema.users_table}" in sql
assert f"{schema.access_state_column} AS access_state" in sql
assert params == {"username": "alice"}
def test_get_user_by_username_disabled_state_is_reported_not_hidden(identity_mode: str) -> None:
"""Строка отдаётся ВСЕГДА, состояние — отдельным полем.
Login обязан отличать «нет такого логина» (None) от «есть, но доступ закрыт»
(строка + не-ACTIVE): от этого зависит выбор события аудита, а прятать
заблокированного за None означало бы потерять эту разницу.
"""
db = _FakeDB(rows=[_user_row(access_state=AccessState.DISABLED)])
user = svc.get_user_by_username(db, "alice")
assert user is not None
assert user["access_state"] is AccessState.DISABLED
assert user["access_state"].can_sign_in is False
def test_get_user_by_username_trial_expired_state(auth_mode: str) -> None:
db = _FakeDB(rows=[_user_row(access_state=AccessState.TRIAL_EXPIRED)])
user = svc.get_user_by_username(db, "alice")
assert user is not None
assert user["access_state"] is AccessState.TRIAL_EXPIRED
assert user["access_state"].can_sign_in is False
def test_get_user_by_username_not_found(identity_mode: str) -> None:
db = _FakeDB(rows=[None])
assert svc.get_user_by_username(db, "ghost") is None
# ---------------------------------------------------------------------------
# revoke_session / revoke_user_sessions
# ---------------------------------------------------------------------------
def test_revoke_session_deletes_and_commits(identity_mode: str) -> None:
db = _FakeDB()
svc.revoke_session(db, "tok")
assert db.committed == 1
sql, params = db.executed[0]
assert f"DELETE FROM {identity_schema().sessions_table}" in sql
assert "token" in sql
assert params == {"token": "tok"}
def test_revoke_user_sessions_deletes_and_commits(identity_mode: str) -> None:
db = _FakeDB()
svc.revoke_user_sessions(db, 7)
assert db.committed == 1
sql, params = db.executed[0]
assert f"DELETE FROM {identity_schema().sessions_table}" in sql
assert "user_id" in sql
assert params == {"user_id": 7}
# ---------------------------------------------------------------------------
# get_db_role_scope
# ---------------------------------------------------------------------------
def test_get_db_role_scope_employee_matches_legacy_pilot() -> None:
paths, deny = svc.get_db_role_scope("employee")
assert "/trade-in/**" in paths
assert "/trade-in/api/v1/**" in paths
assert "/admin/**" in deny
assert "/api/v1/admin/**" in deny
def test_get_db_role_scope_manager_adds_team_path() -> None:
paths, deny = svc.get_db_role_scope("manager")
assert "/trade-in/**" in paths
assert "/api/v1/team/**" in paths
assert "/admin/**" in deny
# «Доля в продаже» и «Кэш» — внутренние инструменты (аналитика рынка / состояние
# кэшей и скраперов), клиентские роли их не видят (решение владельца 2026-07-31).
# В deny И страницы, И их API: один список гейтит пункт меню (Topbar через /me),
# страницу (RouteGuard) и серверные ручки (rbac_guard).
_INTERNAL_TOOL_DENY = (
"/trade-in/sale-share/**",
"/trade-in/cache/**",
"/trade-in/api/v1/buildings/**",
# Глоб, а не точный путь: точный обходится трейлинг-слэшем (см. NB в
# app.services.auth_session над DB_ROLE_PATHS).
"/trade-in/api/v1/trade-in/cache-stats/**",
)
def test_get_db_role_scope_client_roles_deny_internal_tools() -> None:
for role in ("manager", "employee"):
_, deny = svc.get_db_role_scope(role)
for pattern in _INTERNAL_TOOL_DENY:
assert pattern in deny, f"{role} deny missing {pattern}"
def test_get_db_role_scope_admin_full_access() -> None:
paths, deny = svc.get_db_role_scope("admin")
assert paths == ["/**"]
assert deny == []
def test_get_db_role_scope_unknown_role_denies_all() -> None:
paths, deny = svc.get_db_role_scope("ghost")
assert paths == []
assert deny == ["/**"]