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, тот же красный).
439 lines
18 KiB
Python
439 lines
18 KiB
Python
"""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 == ["/**"]
|