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

863 lines
40 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.

"""Integration tests for #2552 auth-core: POST /login, /logout, dual-mode GET /me,
and rbac_guard session-cookie resolution.
Uses the REAL `rbac_guard` (app.core.rbac) + REAL `auth.router` / `me.router` wired
into an isolated FastAPI test app (same pattern as tests/test_rbac.py), with an
in-memory fake DB standing in for the identity registry:
- сессия РЕЕСТРА подменяется на самом низком уровне — `identity_store.SessionLocal`
и `auth_db.auth_session` (см. `tests.support.identity_modes.patch_identity_sessions`),
так что и `identity_session()` (rbac_guard — middleware, FastAPI-DI там нет), и
`Depends(get_identity_db)` (auth.py / me.py) выполняются РЕАЛЬНЫЕ, вместе со своим
ветвлением по `settings.identity_store`;
- `app.core.db.get_db` переопределён через `app.dependency_overrides` — это
продуктовая БД (в дефолтном режиме она же и реестр).
Все они смотрят в ОДИН `_Store` на тест, поэтому сессия, созданная POST /login,
сразу видна собственному DB-раунд-трипу rbac_guard'а на следующем запросе.
⚠️ ДВА РЕЖИМА РЕЕСТРА И ЛОВУШКА FAKE-DB. `_FakeDB` диспатчит по ТЕКСТУ SQL, а
эпик «единый вход» переименовывает таблицы (`tradein_users`/`tradein_sessions` →
`users`/`sessions`) и меняет тип колонки состояния доступа. Литерал
«tradein_users» в диспатчере означал бы, что при `IDENTITY_STORE=auth` ветка
молча перестаёт матчиться, fake отдаёт пустоту, а тест остаётся ЗЕЛЁНЫМ на
сломанном коде. Поэтому имена берутся из `sql_names()` (= `identity_schema()`,
тот же словарь, что у продакшн-кода), а непонятый SQL падает `AssertionError`,
а не возвращает пустой результат.
Дефолт (`identity_store="tradein"`) — сегодняшний прод; тесты без фикстуры
`auth_store` идут именно в нём. Тесты про режим `auth` (в т.ч. про состояние
`trial_expired`, невыразимое булевым `is_active`) — в конце файла.
"""
from __future__ import annotations
import os
import re
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Annotated, Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from fastapi import FastAPI, Header
from fastapi.testclient import TestClient
from app.api.v1 import auth as auth_router
from app.api.v1 import me as me_router
from app.core import auth as auth_mod
from app.core import auth_db, config
from app.core.db import get_db
from app.core.password import hash_password
from app.core.rbac import rbac_guard
from app.services.identity_store import AccessState
from tests.support.identity_modes import (
assert_reads_access_state,
column_value,
patch_identity_sessions,
sql_names,
use_identity_mode,
)
# ---------------------------------------------------------------------------
# Fake DB backing the identity registry (users/sessions таблицы текущего режима)
# ---------------------------------------------------------------------------
class _Store:
def __init__(self) -> None:
self.users: dict[str, dict[str, Any]] = {}
self.sessions: dict[str, dict[str, Any]] = {}
self.sql_log: list[str] = [] # весь SQL, доехавший до «БД» — см. тесты режимов
self._next_id = 1
def add_user(
self,
username: str,
password_hash: str | None,
*,
role: str = "employee",
access_state: AccessState = AccessState.ACTIVE,
display_name: str | None = "Alice A.",
org_name: str | None = "Org LLC",
email: str | None = "alice@example.com",
) -> int:
uid = self._next_id
self._next_id += 1
self.users[username] = {
"id": uid,
"username": username,
"password_hash": password_hash,
"role": role,
# СЫРОЕ значение колонки текущего режима (boolean либо text) — ровно
# то, что вернул бы драйвер; в AccessState его превращает код.
"access_state": column_value(access_state),
"display_name": display_name,
"org_name": org_name,
"email": email,
}
return uid
def set_access_state(self, username: str, state: AccessState) -> None:
"""Меняет состояние доступа уже заведённого юзера (как сделал бы админ/миграция)."""
self.users[username]["access_state"] = column_value(state)
def user_by_id(self, uid: int) -> dict[str, Any] | None:
for u in self.users.values():
if u["id"] == uid:
return u
return None
def add_expired_session(self, token: str, user_id: int) -> None:
now = datetime.now(UTC)
self.sessions[token] = {
"user_id": user_id,
"expires_at": now - timedelta(minutes=1),
"last_seen_at": now - timedelta(minutes=1),
}
class _FakeDB:
"""Minimal Session stand-in dispatching on SQL text — see module docstring."""
def __init__(self, store: _Store) -> None:
self.store = store
def __enter__(self) -> _FakeDB:
return self
def __exit__(self, *exc: object) -> bool:
return False
def close(self) -> None:
pass
def commit(self) -> None:
pass
def rollback(self) -> None:
pass
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> SimpleNamespace:
sql = str(stmt)
p = params or {}
# Имена таблиц берутся ИЗ КОДА (identity_schema), а не из литералов —
# см. «ЛОВУШКА FAKE-DB» в модульном docstring.
names = sql_names()
self.store.sql_log.append(sql)
if f"INSERT INTO {names.sessions}" in sql:
now = datetime.now(UTC)
self.store.sessions[p["token"]] = {
"user_id": p["user_id"],
"expires_at": now + timedelta(hours=p["ttl_hours"]),
"last_seen_at": now,
}
return SimpleNamespace(fetchone=lambda: None)
if f"UPDATE {names.sessions}" in sql and "SET last_seen_at" in sql:
sess = self.store.sessions.get(p["token"])
if sess is not None:
now = datetime.now(UTC)
sess["last_seen_at"] = now
sess["expires_at"] = now + timedelta(hours=p["ttl_hours"])
return SimpleNamespace(fetchone=lambda: None)
if f"DELETE FROM {names.sessions} WHERE token" in sql:
self.store.sessions.pop(p["token"], None)
return SimpleNamespace(fetchone=lambda: None)
if f"DELETE FROM {names.sessions} WHERE user_id" in sql:
uid = p["user_id"]
for tok in [t for t, s in self.store.sessions.items() if s["user_id"] == uid]:
del self.store.sessions[tok]
return SimpleNamespace(fetchone=lambda: None)
if f"FROM {names.sessions} s" in sql and f"JOIN {names.users} u" in sql:
assert_reads_access_state(sql, names)
sess = self.store.sessions.get(p["token"])
if sess is None:
return SimpleNamespace(fetchone=lambda: None)
user = self.store.user_by_id(sess["user_id"])
if user is None:
return SimpleNamespace(fetchone=lambda: None)
# Колонка состояния приезжает под алиасом `access_state` в ОБОИХ
# режимах (`u.<колонка> AS access_state` в реальном SELECT'е);
# значение — сырое, типа своей схемы.
row = SimpleNamespace(
user_id=sess["user_id"],
expires_at=sess["expires_at"],
last_seen_at=sess["last_seen_at"],
username=user["username"],
role=user["role"],
display_name=user["display_name"],
org_name=user["org_name"],
email=user["email"],
access_state=user["access_state"],
)
return SimpleNamespace(fetchone=lambda: row)
if f"FROM {names.users}" in sql and "WHERE username = :username" in sql:
assert_reads_access_state(sql, names)
user = self.store.users.get(p["username"])
if user is None:
return SimpleNamespace(fetchone=lambda: None)
row = SimpleNamespace(**user)
return SimpleNamespace(fetchone=lambda: row)
raise AssertionError(f"unhandled fake SQL in test_auth_api: {sql!r}")
# ---------------------------------------------------------------------------
# Test app
# ---------------------------------------------------------------------------
def _build_test_app(store: _Store) -> FastAPI:
app = FastAPI()
app.middleware("http")(rbac_guard)
app.include_router(auth_router.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(me_router.router, prefix="/api/v1", tags=["me"])
@app.get("/api/v1/trade-in/dummy")
async def tradein_dummy() -> dict:
return {"ok": True}
@app.get("/api/v1/trade-in/whoami")
async def tradein_whoami(
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> dict:
"""Echoes the X-Authenticated-User header exactly as a downstream handler
(`_assert_estimate_access*`, `account_quota`, etc.) would see it — used to
assert session-identity wins over a client-forged header (#2552 spoof fix)."""
return {"user": x_authenticated_user}
# Внутренние инструменты, закрытые от клиентских DB-ролей 2026-07-31
# («Доля в продаже» / «Кэш»). Ручки настоящие (не заглушки rbac_guard'а),
# чтобы 403 приходил именно от scope-чека, а не от отсутствия роута.
@app.get("/api/v1/buildings/sale-share")
async def buildings_sale_share() -> dict:
return {"ok": True}
@app.get("/api/v1/trade-in/cache-stats")
async def tradein_cache_stats() -> dict:
return {"ok": True}
def _override_get_db(): # generator dependency — matches app.core.db.get_db shape
yield _FakeDB(store)
app.dependency_overrides[get_db] = _override_get_db
return app
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
auth_mod.reset_cache_for_tests()
auth_router._LOGIN_LIMITER._hits.clear()
monkeypatch.setattr(config.settings, "auth_mode", "dual")
# Каждый тест стартует в ДЕФОЛТНОМ режиме реестра (сегодняшний прод), даже
# если предыдущий переключался на `auth`.
use_identity_mode(monkeypatch, "tradein")
@pytest.fixture
def store() -> _Store:
return _Store()
@pytest.fixture
def auth_store(store: _Store, monkeypatch: pytest.MonkeyPatch) -> _Store:
"""Тот же `store`, но реестр — БД `auth` (`users`/`sessions`, text-состояние).
Запрашивай ПЕРЕД `client` в списке аргументов теста: `client` строится уже с
учётом режима (`_build_test_app` читает его лениво, но `store.add_user`
сохраняет значение колонки по режиму НА МОМЕНТ ВЫЗОВА).
"""
use_identity_mode(monkeypatch, "auth")
return store
@pytest.fixture
def client(store: _Store, monkeypatch: pytest.MonkeyPatch) -> TestClient:
# Подменяем сессию РЕЕСТРА на обоих её источниках сразу, а не ветвление по
# режиму: `identity_session()` / `get_identity_db()` остаются настоящими.
patch_identity_sessions(monkeypatch, lambda: _FakeDB(store))
# base_url=https:// — login sets the session cookie with Secure=True (real prod
# behaviour, not weakened for tests); httpx's cookie jar silently drops Secure
# cookies on a plain-http connection, so a plain http://testserver client would
# never resend the cookie on subsequent requests within the same test.
return TestClient(_build_test_app(store), base_url="https://testserver")
# ---------------------------------------------------------------------------
# POST /login
# ---------------------------------------------------------------------------
def test_login_happy_path_sets_cookie(client: TestClient, store: _Store) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
resp = client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
assert resp.status_code == 200, resp.text
assert resp.json() == {"ok": True}
cookie_name = config.settings.session_cookie_name
assert cookie_name in resp.cookies
assert resp.cookies[cookie_name]
# Сессия реально создана в сторе под этим токеном.
assert resp.cookies[cookie_name] in store.sessions
def test_login_wrong_password_401_and_logs_failed_event(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
events: list[dict[str, Any]] = []
monkeypatch.setattr(auth_router, "schedule_event", lambda **kw: events.append(kw))
resp = client.post("/api/v1/auth/login", json={"username": "alice", "password": "wrong"})
assert resp.status_code == 401
assert "detail" in resp.json()
assert config.settings.session_cookie_name not in resp.cookies
assert any(e["event_type"] == "login_failed" for e in events)
failed = next(e for e in events if e["event_type"] == "login_failed")
assert failed["username"] == "alice"
# Raw-пароль никогда не попадает в событие.
assert "wrong" not in str(failed)
def test_login_unknown_username_401_generic_message(client: TestClient) -> None:
resp = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "whatever"})
assert resp.status_code == 401
# НЕ раскрываем, что юзера не существует — то же сообщение, что и wrong-password.
body_ghost = resp.json()["detail"]
resp2 = client.post("/api/v1/auth/login", json={"username": "ghost2", "password": "x"})
assert resp2.json()["detail"] == body_ghost
def test_login_inactive_user_401(client: TestClient, store: _Store) -> None:
store.add_user(
"bob", hash_password("Secret123!"), role="employee", access_state=AccessState.DISABLED
)
resp = client.post("/api/v1/auth/login", json={"username": "bob", "password": "Secret123!"})
assert resp.status_code == 401
def test_login_null_password_hash_401(client: TestClient, store: _Store) -> None:
store.add_user("carol", None, role="employee")
resp = client.post("/api/v1/auth/login", json={"username": "carol", "password": "anything"})
assert resp.status_code == 401
def test_login_always_calls_verify_password_timing_oracle_guard(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""#2552 post-review Medium 2: `verify_password` должен выполняться ровно
один раз на КАЖДУЮ попытку логина — включая неизвестный username и NULL
password_hash — иначе короткое замыкание даёт наблюдаемый timing-oracle
для user-enumeration. Тест не измеряет тайминг (флейки в CI), а проверяет
сам факт + аргумент вызова через monkeypatch-счётчик."""
store.add_user("alice", hash_password("Secret123!"), role="employee")
store.add_user("nullhash", None, role="employee")
calls: list[str] = []
real_verify = auth_router.verify_password
def _counting_verify(plain: str, hashed: str) -> bool:
calls.append(hashed)
return real_verify(plain, hashed)
monkeypatch.setattr(auth_router, "verify_password", _counting_verify)
resp_unknown = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "x"})
assert resp_unknown.status_code == 401
resp_null_hash = client.post(
"/api/v1/auth/login", json={"username": "nullhash", "password": "x"}
)
assert resp_null_hash.status_code == 401
resp_wrong_pw = client.post(
"/api/v1/auth/login", json={"username": "alice", "password": "wrong"}
)
assert resp_wrong_pw.status_code == 401
assert len(calls) == 3
# Unknown user / NULL hash — сверяется против dummy-хеша, не против NULL.
assert calls[0] == auth_router._DUMMY_PASSWORD_HASH
assert calls[1] == auth_router._DUMMY_PASSWORD_HASH
# Реальный юзер с реальным hash — НЕ dummy.
assert calls[2] != auth_router._DUMMY_PASSWORD_HASH
def test_login_rate_limit_429(client: TestClient, store: _Store) -> None:
store.add_user("dave", hash_password("Secret123!"), role="employee")
limit = config.settings.login_rate_limit
for _ in range(limit):
resp = client.post("/api/v1/auth/login", json={"username": "dave", "password": "wrong"})
assert resp.status_code == 401
resp = client.post("/api/v1/auth/login", json={"username": "dave", "password": "wrong"})
assert resp.status_code == 429
assert "Retry-After" in resp.headers
# ---------------------------------------------------------------------------
# POST /logout
# ---------------------------------------------------------------------------
def test_logout_revokes_session_and_clears_cookie(client: TestClient, store: _Store) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
login_resp = client.post(
"/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"}
)
token = login_resp.cookies[config.settings.session_cookie_name]
assert token in store.sessions
logout_resp = client.post("/api/v1/auth/logout")
assert logout_resp.status_code == 200
assert logout_resp.json() == {"ok": True}
assert token not in store.sessions
# ---------------------------------------------------------------------------
# GET /me — session-first + dual-mode legacy fallback
# ---------------------------------------------------------------------------
def test_me_with_session_cookie_returns_db_role(client: TestClient, store: _Store) -> None:
store.add_user(
"alice",
hash_password("Secret123!"),
role="employee",
display_name="Алиса",
org_name="ООО Ромашка",
email="alice@romashka.ru",
)
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
resp = client.get("/api/v1/me")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["username"] == "alice"
assert body["role"] == "employee"
assert "/trade-in/**" in body["allowed_paths"]
assert "/admin/**" in body["deny_paths"]
# Пункты меню «Доля в продаже» / «Кэш» прячет Topbar, фильтруя NAV_ITEMS по
# deny_paths ИЗ /me — т.е. видимость держится на ЭТОМ выводе, а не только на
# DB_ROLE_PATHS. Сборка dict-а в app/api/v1/me.py может регрессировать
# независимо от get_db_role_scope, поэтому пиним её здесь.
assert "/trade-in/sale-share/**" in body["deny_paths"]
assert "/trade-in/cache/**" in body["deny_paths"]
assert body["display_name"] == "Алиса"
assert body["org"] == "ООО Ромашка"
assert body["email"] == "alice@romashka.ru"
def test_me_manager_role_gets_team_path(client: TestClient, store: _Store) -> None:
store.add_user("mgr", hash_password("Secret123!"), role="manager")
client.post("/api/v1/auth/login", json={"username": "mgr", "password": "Secret123!"})
resp = client.get("/api/v1/me")
assert resp.status_code == 200
body = resp.json()
assert "/api/v1/team/**" in body["allowed_paths"]
# Тот же пин, что и для employee: manager (роль praktika) не должен получать
# из /me deny-список без внутренних разделов — иначе пункты вернутся в топбар.
assert "/trade-in/sale-share/**" in body["deny_paths"]
assert "/trade-in/cache/**" in body["deny_paths"]
def test_me_without_cookie_dual_mode_legacy_still_works(client: TestClient) -> None:
"""Regression guard: без сессии, auth_mode=dual — legacy X-Authenticated-User
путь через roles.yaml работает БЕЗ ИЗМЕНЕНИЙ."""
resp = client.get("/api/v1/me", headers={"X-Authenticated-User": "admin"})
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["username"] == "admin"
assert body["role"] == "admin"
assert body["allowed_paths"] == ["/**"]
def test_me_no_cookie_no_header_401(client: TestClient) -> None:
resp = client.get("/api/v1/me")
assert resp.status_code == 401
def test_expired_session_falls_back_to_no_auth(client: TestClient, store: _Store) -> None:
"""Истёкшая сессия трактуется как отсутствие cookie: без legacy-заголовка — 401."""
uid = store.add_user("alice", hash_password("Secret123!"), role="employee")
store.add_expired_session("expired-tok", uid)
client.cookies.set(config.settings.session_cookie_name, "expired-tok")
resp = client.get("/api/v1/trade-in/dummy")
assert resp.status_code == 401
def test_expired_session_falls_back_to_legacy_header_in_dual_mode(
client: TestClient, store: _Store
) -> None:
"""Истёкшая сессия + валидный legacy header в dual-mode — header отрабатывает."""
uid = store.add_user("alice", hash_password("Secret123!"), role="employee")
store.add_expired_session("expired-tok", uid)
client.cookies.set(config.settings.session_cookie_name, "expired-tok")
resp = client.get(
"/api/v1/trade-in/dummy",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True}
# ---------------------------------------------------------------------------
# rbac_guard dual vs db_only
# ---------------------------------------------------------------------------
def test_db_only_mode_rejects_legacy_header_without_session(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(config.settings, "auth_mode", "db_only")
resp = client.get(
"/api/v1/trade-in/dummy",
headers={"X-Authenticated-User": "admin"},
)
assert resp.status_code == 401
assert "session" in resp.json()["detail"].lower()
def test_db_only_mode_accepts_valid_session(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
monkeypatch.setattr(config.settings, "auth_mode", "db_only")
resp = client.get("/api/v1/trade-in/dummy")
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_session_user_can_reach_tradein_but_not_admin(client: TestClient, store: _Store) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
ok = client.get("/api/v1/trade-in/dummy")
assert ok.status_code == 200
denied = client.get("/api/v1/admin/dummy")
# rbac_guard's admin-gate matches the path regex BEFORE routing even happens
# (route isn't registered on this test app) — role=employee != admin -> 403,
# never a 404 (a bare "any non-2xx" assertion would mask a rbac_guard typo).
assert denied.status_code == 403
# ---------------------------------------------------------------------------
# 2026-07-31: внутренние разделы («Доля в продаже» / «Кэш») закрыты от клиентов —
# СКВОЗЬ РЕАЛЬНЫЙ rbac_guard по SESSION-ветке (from_session=True).
#
# Тесты в tests/test_rbac.py проверяют матчеры напрямую + guard по ЛЕГАСИ
# trusted-header ветке (is_path_allowed / roles.yaml). Но в проде клиентские
# аккаунты (praktika и прочие DB-юзеры) ходят именно session-cookie'ой, где
# scope считает ДРУГАЯ ветка — `_db_role_path_allowed(role, external_path)`.
# Без тестов ниже её можно было сломать, не уронив ни одного теста.
#
# Пути тут — ВНУТРЕННИЕ (Caddy срезает внешний /trade-in), rbac_guard
# восстанавливает внешний как '/trade-in' + path.
# ---------------------------------------------------------------------------
_INTERNAL_TOOL_API = ("/api/v1/buildings/sale-share", "/api/v1/trade-in/cache-stats")
def test_session_manager_denied_on_internal_tool_api(client: TestClient, store: _Store) -> None:
store.add_user("mgr", hash_password("Secret123!"), role="manager")
client.post("/api/v1/auth/login", json={"username": "mgr", "password": "Secret123!"})
for path in _INTERNAL_TOOL_API:
resp = client.get(path)
assert resp.status_code == 403, f"manager {path}: {resp.status_code} {resp.text}"
assert "forbidden for role" in resp.json()["detail"].lower()
# ...и при этом основной продукт для той же сессии открыт (иначе тест выше
# проходил бы и на «сломали scope целиком»).
ok = client.get("/api/v1/trade-in/dummy")
assert ok.status_code == 200, ok.text
def test_session_employee_denied_on_internal_tool_api(client: TestClient, store: _Store) -> None:
store.add_user("emp", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "emp", "password": "Secret123!"})
for path in _INTERNAL_TOOL_API:
resp = client.get(path)
assert resp.status_code == 403, f"employee {path}: {resp.status_code} {resp.text}"
assert "forbidden for role" in resp.json()["detail"].lower()
ok = client.get("/api/v1/trade-in/dummy")
assert ok.status_code == 200, ok.text
def test_session_admin_keeps_internal_tool_api(client: TestClient, store: _Store) -> None:
"""Контрольная группа: DB-роль admin ('/**') разделы по-прежнему видит."""
store.add_user("root", hash_password("Secret123!"), role="admin")
client.post("/api/v1/auth/login", json={"username": "root", "password": "Secret123!"})
for path in _INTERNAL_TOOL_API:
resp = client.get(path)
assert resp.status_code == 200, f"admin {path}: {resp.text}"
# ---------------------------------------------------------------------------
# #2552 post-review CRITICAL fix: session identity must win over a spoofed
# client-sent X-Authenticated-User header (was a skip-if-present bug — the
# forged header used to override the session for every downstream reader of
# the raw header: _assert_estimate_access*, account_quota, /trade-in/history,
# support.py — in BOTH auth_mode=dual and db_only).
# ---------------------------------------------------------------------------
def test_session_identity_wins_over_spoofed_header_dual_mode(
client: TestClient, store: _Store
) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
store.add_user("victim", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
resp = client.get(
"/api/v1/trade-in/whoami",
headers={"X-Authenticated-User": "victim"},
)
assert resp.status_code == 200
assert resp.json()["user"] == "alice"
def test_session_identity_wins_over_spoofed_header_db_only_mode(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
store.add_user("victim", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
monkeypatch.setattr(config.settings, "auth_mode", "db_only")
resp = client.get(
"/api/v1/trade-in/whoami",
headers={"X-Authenticated-User": "victim"},
)
assert resp.status_code == 200
assert resp.json()["user"] == "alice"
def test_cyrillic_username_session_propagation_does_not_500(
client: TestClient, store: _Store
) -> None:
"""#2552 post-review Medium 1: `.encode("latin-1")` без errors="replace" на
кириллическом username крашил бы КАЖДЫЙ запрос такого юзера с 500."""
store.add_user("алиса", hash_password("Secret123!"), role="employee")
login_resp = client.post(
"/api/v1/auth/login", json={"username": "алиса", "password": "Secret123!"}
)
assert login_resp.status_code == 200, login_resp.text
resp = client.get("/api/v1/trade-in/whoami")
assert resp.status_code == 200, resp.text
# latin-1 "replace" гарантированно не крашит — точное значение (что именно
# получится из non-latin1 байт) не является контрактом, важно отсутствие 500.
assert resp.json()["user"] is not None
# ---------------------------------------------------------------------------
# Эпик «единый вход»: режим IDENTITY_STORE=auth (общий реестр в БД `auth`).
#
# Всё выше идёт в ДЕФОЛТНОМ режиме — он же прод — и служит регрессионным
# доказательством «после мержа работает точно как сейчас». Ниже — поведение,
# которое появляется ТОЛЬКО после переезда: трёхзначное состояние доступа
# (`active` / `trial_expired` / `disabled`) вместо булева `is_active`.
# ---------------------------------------------------------------------------
def test_default_mode_talks_to_tradein_tables_only(client: TestClient, store: _Store) -> None:
"""Дефолт трогает РОВНО сегодняшние таблицы — и ни одной таблицы реестра `auth`.
Пин на случай, если флаг когда-нибудь начнёт «протекать» (например, дефолт
поменяют или ветвление уедет не туда): расхождение здесь означало бы, что
прод после мержа пошёл в другую БД.
"""
store.add_user("alice", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
assert client.get("/api/v1/me").status_code == 200
joined = "\n".join(store.sql_log)
assert "tradein_users" in joined
assert "tradein_sessions" in joined
# Ни один запрос не адресован таблицам общего реестра.
assert not re.search(r"\b(FROM|INTO|UPDATE|JOIN)\s+users\b", joined)
assert not re.search(r"\b(FROM|INTO|UPDATE|JOIN)\s+sessions\b", joined)
# И engine БД `auth` даже не создавался (AUTH_DATABASE_URL на проде пуст —
# ленивое построение обязано не случиться, иначе запрос упал бы).
assert auth_db._engine is None
def test_auth_mode_talks_to_shared_registry_tables(auth_store: _Store, client: TestClient) -> None:
"""Зеркало предыдущего: при IDENTITY_STORE=auth запросы уходят в users/sessions."""
auth_store.add_user("alice", hash_password("Secret123!"), role="employee")
resp = client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
assert resp.status_code == 200, resp.text
assert client.get("/api/v1/me").status_code == 200
joined = "\n".join(auth_store.sql_log)
assert "tradein_users" not in joined
assert "tradein_sessions" not in joined
assert re.search(r"FROM\s+users\b", joined)
assert re.search(r"INSERT INTO\s+sessions\b", joined)
def test_login_trial_expired_403_with_code_and_no_session(
auth_store: _Store, client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""ВЕРНЫЙ пароль + `trial_expired` → 403 с машиночитаемым кодом, сессии НЕТ.
Единственный не-generic ответ логина: аккаунт существует и владелец это уже
доказал паролем, так что осмысленный текст постороннему ничего не выдаёт.
"""
auth_store.add_user(
"trialguy",
hash_password("Secret123!"),
role="employee",
access_state=AccessState.TRIAL_EXPIRED,
)
events: list[dict[str, Any]] = []
monkeypatch.setattr(auth_router, "schedule_event", lambda **kw: events.append(kw))
resp = client.post(
"/api/v1/auth/login", json={"username": "trialguy", "password": "Secret123!"}
)
assert resp.status_code == 403, resp.text
detail = resp.json()["detail"]
# Контракт для фронта — `code`, а не текст сообщения.
assert detail["code"] == "access_expired"
assert detail["message"]
# Сессия не выдана: ни куки, ни строки в реестре.
assert config.settings.session_cookie_name not in resp.cookies
assert auth_store.sessions == {}
assert [e["event_type"] for e in events] == ["login_blocked_expired"]
def test_login_wrong_password_on_trial_expired_is_generic_401(
auth_store: _Store, client: TestClient
) -> None:
"""НЕверный пароль на `trial_expired` → тот же generic 401, что у чужого логина.
Иначе отдельный 403 превращается в оракул существования аккаунта: перебором
можно было бы перечислить логины, не зная ни одного пароля.
"""
auth_store.add_user(
"trialguy",
hash_password("Secret123!"),
role="employee",
access_state=AccessState.TRIAL_EXPIRED,
)
wrong_pw = client.post("/api/v1/auth/login", json={"username": "trialguy", "password": "nope"})
ghost = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "nope"})
assert wrong_pw.status_code == 401
# Побайтово тот же ответ, что и на несуществующий логин.
assert wrong_pw.json() == ghost.json()
assert auth_store.sessions == {}
def test_login_disabled_is_generic_401_not_403(auth_store: _Store, client: TestClient) -> None:
"""`disabled` + верный пароль → generic 401, НЕ 403: заблокированный аккаунт
для пользователя неотличим от несуществующего (в отличие от `trial_expired`,
у которого есть свой экран)."""
auth_store.add_user(
"blocked",
hash_password("Secret123!"),
role="employee",
access_state=AccessState.DISABLED,
)
blocked = client.post(
"/api/v1/auth/login", json={"username": "blocked", "password": "Secret123!"}
)
ghost = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "x"})
assert blocked.status_code == 401
assert blocked.json() == ghost.json()
assert auth_store.sessions == {}
def test_unknown_access_state_is_fail_closed_401(auth_store: _Store, client: TestClient) -> None:
"""Состояние, которого код не знает (миграция уехала вперёд кода), НЕ пускает."""
auth_store.add_user("newbie", hash_password("Secret123!"), role="employee")
auth_store.users["newbie"]["access_state"] = "pending_review"
resp = client.post("/api/v1/auth/login", json={"username": "newbie", "password": "Secret123!"})
assert resp.status_code == 401
assert auth_store.sessions == {}
@pytest.mark.parametrize("state", [AccessState.TRIAL_EXPIRED, AccessState.DISABLED])
def test_live_session_dies_when_access_state_leaves_active(
auth_store: _Store, client: TestClient, state: AccessState
) -> None:
"""Уже выданная сессия перестаёт работать СРАЗУ, как только состояние != active.
Без этого sliding-refresh (`get_session_user` продлевает expires_at на каждом
запросе) держал бы сессию истёкшего/заблокированного бесконечно долго.
"""
auth_store.add_user("alice", hash_password("Secret123!"), role="employee")
login = client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
assert login.status_code == 200
assert client.get("/api/v1/trade-in/dummy").status_code == 200
auth_store.set_access_state("alice", state)
# auth_mode=dual, но legacy-заголовка нет → сессия больше не резолвится → 401.
assert client.get("/api/v1/trade-in/dummy").status_code == 401
assert client.get("/api/v1/me").status_code == 401
def test_session_identity_wins_over_spoofed_header_auth_store(
auth_store: _Store, client: TestClient
) -> None:
"""Перезапись X-Authenticated-User в ASGI-scope работает и на общем реестре.
Тот же CRITICAL, что и в дефолтном режиме (см. выше): подделанный клиентом
заголовок не должен выигрывать у резолвленной сессии ни в одном режиме — эти
~15 downstream-хендлеров читают сырой заголовок и про режим ничего не знают.
"""
auth_store.add_user("alice", hash_password("Secret123!"), role="employee")
auth_store.add_user("victim", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
resp = client.get("/api/v1/trade-in/whoami", headers={"X-Authenticated-User": "victim"})
assert resp.status_code == 200
assert resp.json()["user"] == "alice"
def test_auth_mode_role_scope_and_logout(auth_store: _Store, client: TestClient) -> None:
"""Роль/скоуп и logout на общем реестре ведут себя как в дефолтном режиме."""
auth_store.add_user("mgr", hash_password("Secret123!"), role="manager")
login = client.post("/api/v1/auth/login", json={"username": "mgr", "password": "Secret123!"})
token = login.cookies[config.settings.session_cookie_name]
assert token in auth_store.sessions
body = client.get("/api/v1/me").json()
assert body["role"] == "manager"
assert "/api/v1/team/**" in body["allowed_paths"]
assert "/trade-in/sale-share/**" in body["deny_paths"]
assert client.post("/api/v1/auth/logout").status_code == 200
assert token not in auth_store.sessions