gendesign/tradein-mvp/backend/tests/test_auth_api.py
bot-backend bab4b4f8ff
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m28s
Deploy Trade-In / test (push) Has been cancelled
Merge pull request 'fix(tradein/rbac): 401-оракул закрыт — несуществующий путь под публичным префиксом отвечает так же, как любой другой мусор' (#3352) from fix/3324-rbac-401-oracle into main
2026-09-05 18:19:27 +00:00

1680 lines
90 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 asyncio
import logging
import os
import re
import threading
import time
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 httpx
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 import password as password_mod
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}
# По той же причине ручка настоящая: с #3324 guard не отвечает 401/403 за
# несуществующий путь (несуществующее обязано быть неотличимо от
# несуществующего), поэтому admin-гейт проверяется на реальном роуте.
@app.get("/api/v1/admin/dummy")
async def admin_dummy() -> 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()
auth_router._USERNAME_FAIL_LIMITER._hits.clear()
# Агрегатор отказов по насыщению (#2715) — тоже глобал процесса: без сброса
# недосчитанные отказы одного теста всплывают в записи другого.
monkeypatch.setattr(auth_router, "_saturation_rejected", 0)
monkeypatch.setattr(auth_router, "_saturation_reported_at", None)
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 = password_mod.verify_password
def _counting_verify(plain: str, hashed: str) -> bool:
calls.append(hashed)
return real_verify(plain, hashed)
# Патчим тело в app.core.password, а не имя в auth: с #2665 хендлер зовёт
# `verify_password_bounded`, а та ищет `verify_password` в своём модуле на
# каждый вызов — так счётчик считает РЕАЛЬНЫЕ bcrypt-сверки, а не обёртку.
monkeypatch.setattr(password_mod, "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
# ---------------------------------------------------------------------------
# #2571 — глобальный потолок попыток НА ИМЯ (распределённый перебор)
# ---------------------------------------------------------------------------
def _throttle_settings(
monkeypatch: pytest.MonkeyPatch, *, threshold: int, max_delay_s: float
) -> None:
"""Порог и потолок задержки под тест: реальные 20/час и 8с ждать некогда."""
monkeypatch.setattr(config.settings, "login_username_fail_threshold", threshold)
monkeypatch.setattr(config.settings, "login_username_throttle_max_delay_s", max_delay_s)
def _capture_events(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
monkeypatch.setattr(auth_router, "schedule_event", lambda **kw: events.append(kw))
return events
def _failed_login_delays(events: list[dict[str, Any]]) -> list[float]:
return [e["payload"]["throttle_delay_s"] for e in events if e["event_type"] == "login_failed"]
def test_throttle_delay_grows_and_caps(monkeypatch: pytest.MonkeyPatch) -> None:
"""Задержка = 0 до порога, дальше удваивается от 1с и упирается в потолок.
Потолок обязателен: без него растущая задержка вырождается в ту самую
блокировку учётки, которой #2571 сознательно избегает (DoS против человека).
"""
_throttle_settings(monkeypatch, threshold=3, max_delay_s=4.0)
assert auth_router._throttle_delay_s(1) == 0.0
assert auth_router._throttle_delay_s(3) == 0.0 # ровно порог — ещё не перебор
assert auth_router._throttle_delay_s(4) == 1.0
assert auth_router._throttle_delay_s(5) == 2.0
assert auth_router._throttle_delay_s(6) == 4.0
assert auth_router._throttle_delay_s(7) == 4.0 # потолок
assert auth_router._throttle_delay_s(1000) == 4.0
# Счётчик ничем не ограничен сверху (`record()` только добавляет метку), а
# `min()` вычисляет ОБА аргумента. Без зажатого показателя степени
# `float(2 ** (excess - 1))` при ~1045 неудачах падает с OverflowError, и
# защита начинает отдавать 500 без задержки и без аудита — ровно под той
# нагрузкой, ради которой писалась. 1000 выше проходило впритык под обрывом.
assert auth_router._throttle_delay_s(5_000) == 4.0
assert auth_router._throttle_delay_s(10**6) == 4.0
def test_distributed_bruteforce_one_username_many_ips_hits_global_ceiling(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""DoD 1: перебор одного имени с РАЗНЫХ адресов упирается в потолок на имя.
Каждая попытка приходит со своего IP, поэтому per-(username, IP) лимит
(`_LOGIN_LIMITER`, 5/300с) не срабатывает НИ РАЗУ — ровно та дыра, из-за
которой issue заведена. Ловит её только глобальный счётчик на имя.
"""
store.add_user("victim", hash_password("Secret123!"), role="employee")
_throttle_settings(monkeypatch, threshold=3, max_delay_s=0.01)
events = _capture_events(monkeypatch)
for i in range(6):
resp = client.post(
"/api/v1/auth/login",
json={"username": "victim", "password": f"guess{i}"},
headers={"x-forwarded-for": f"10.0.0.{i}"},
)
# per-IP бюджет не исчерпан ни на одном адресе — никаких 429.
assert resp.status_code == 401, resp.text
delays = _failed_login_delays(events)
assert len(delays) == 6
# Первые три — в пределах порога, дальше замедление.
assert delays[:3] == [0.0, 0.0, 0.0]
assert all(d > 0 for d in delays[3:])
# Счётчик глобальный: считает попытки со ВСЕХ адресов, а не по каждому свой.
fails = [e["payload"]["username_fails_in_window"] for e in events]
assert fails == [1, 2, 3, 4, 5, 6]
def test_throttle_actually_delays_the_response(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Задержка именно ЖДЁТСЯ, а не только пишется в аудит.
Отдельный тест, потому что все остальные читают величину задержки из
события: без этой проверки реализация, которая посчитала задержку и забыла
`await`, оставила бы весь набор зелёным.
"""
store.add_user("slowpoke", hash_password("Secret123!"), role="employee")
# threshold=0 → тормозим уже первую неудачу; потолок 1с — цена одного теста.
_throttle_settings(monkeypatch, threshold=0, max_delay_s=1.0)
started = time.monotonic()
resp = client.post("/api/v1/auth/login", json={"username": "slowpoke", "password": "wrong"})
elapsed = time.monotonic() - started
assert resp.status_code == 401
assert elapsed >= 1.0
def test_db_connection_released_before_sleeping(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Соединение с БД возвращается в пул ДО сна, а не удерживается всю задержку.
На проде `get_identity_db` в дефолтном режиме отдаёт ту же сессию, что
`get_db` (движок с QueuePool 5+10, pool_timeout=30), а `get_user_by_username`
оставляет её в открытой транзакции. Сон внутри этой области жизни держал бы
соединение занятым все 8с: ~15 одновременно спящих неудач выбирают пул
целиком, и дальше ЛЮБОЙ эндпоинт ждёт checkout 30с и падает — отказ в
обслуживании против всех, ради ухода от которого замедление и выбиралось
вместо блокировки.
Проверяем порядком, а не мокой пула: если `close()` случился до сна, между
ним и концом ответа лежит вся задержка; если бы сессию закрывала только
зависимость (то есть после сна) — зазор был бы околонулевым.
"""
store.add_user("holder", hash_password("Secret123!"), role="employee")
_throttle_settings(monkeypatch, threshold=0, max_delay_s=1.0)
closes: list[float] = []
real_close = _FakeDB.close
def _spy_close(self: _FakeDB) -> None:
closes.append(time.monotonic())
real_close(self)
monkeypatch.setattr(_FakeDB, "close", _spy_close)
resp = client.post("/api/v1/auth/login", json={"username": "holder", "password": "wrong"})
finished = time.monotonic()
assert resp.status_code == 401
assert closes, "сессия не закрывалась вовсе"
assert finished - closes[0] >= 1.0
def test_typo_does_not_throttle_and_correct_password_still_works(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""DoD 2: живой человек с опечаткой не наказан.
Порог штатный (20/час), три опечатки до него не дотягивают — задержки нет,
а верный пароль пускает сразу же. Замедление применяется ТОЛЬКО к неудаче,
поэтому даже перебранный порог не мешает владельцу пароля войти.
"""
store.add_user("erin", hash_password("Secret123!"), role="employee")
events = _capture_events(monkeypatch)
for _ in range(3):
resp = client.post("/api/v1/auth/login", json={"username": "erin", "password": "Secret123"})
assert resp.status_code == 401
assert _failed_login_delays(events) == [0.0, 0.0, 0.0]
ok = client.post("/api/v1/auth/login", json={"username": "erin", "password": "Secret123!"})
assert ok.status_code == 200, ok.text
assert config.settings.session_cookie_name in ok.cookies
def test_counter_decays_when_window_passes(monkeypatch: pytest.MonkeyPatch) -> None:
"""Вторая половина DoD 2: наказание не накапливается вечно.
Окно скользящее, старые неудачи выпадают сами — снимать ничего вручную не
нужно. Проверяем на самом счётчике, а не через HTTP: один вызов login стоит
полного bcrypt (~0.25с), так что игрушечное окно истекало бы прямо посреди
цикла запросов и тест мерил бы скорость хеширования, а не спад счётчика.
"""
_throttle_settings(monkeypatch, threshold=1, max_delay_s=4.0)
limiter = auth_router._USERNAME_FAIL_LIMITER
monkeypatch.setattr(limiter, "_window_s", 0.2)
assert [limiter.record("frank") for _ in range(3)] == [1, 2, 3]
assert auth_router._throttle_delay_s(3) > 0
time.sleep(0.25) # окно прошло — прошлые неудачи больше не считаются
assert limiter.record("frank") == 1
assert auth_router._throttle_delay_s(1) == 0.0
def test_username_length_is_bounded(client: TestClient) -> None:
"""Сырое имя становится ключом обоих лимитеров, а их словарь чистится только
при >10000 ключей и только от пустых корзин — при окне в час чистить нечего.
Границу длины держим на 64 (верх CHECK'а реестра), чтобы килобайтные имена
не растили память ключами."""
resp = client.post("/api/v1/auth/login", json={"username": "x" * 65, "password": "p"})
assert resp.status_code == 422
# 64 — всё ещё валидная длина, отвечаем обычным generic-отказом.
ok_len = client.post("/api/v1/auth/login", json={"username": "x" * 64, "password": "p"})
assert ok_len.status_code == 401
def test_throttle_identical_for_existing_and_unknown_username(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Замедление не должно стать оракулом существования учётки.
Если тормозить только существующие имена, злоумышленник перечисляет живые
логины по времени ответа, не зная ни одного пароля — та же user-enumeration,
от которой защищают одинаковый 401 и безусловный bcrypt (`_DUMMY_PASSWORD_HASH`),
только по другому каналу. Счётчик ведётся по присланному имени БЕЗ проверки
в реестре, поэтому обе последовательности обязаны совпасть.
"""
store.add_user("real", hash_password("Secret123!"), role="employee")
_throttle_settings(monkeypatch, threshold=2, max_delay_s=0.01)
events = _capture_events(monkeypatch)
real_responses = []
for i in range(4):
real_responses.append(
client.post(
"/api/v1/auth/login",
json={"username": "real", "password": "wrong"},
headers={"x-forwarded-for": f"10.1.0.{i}"},
)
)
real_delays = _failed_login_delays(events)
events.clear()
ghost_responses = []
for i in range(4):
ghost_responses.append(
client.post(
"/api/v1/auth/login",
json={"username": "ghost", "password": "wrong"},
headers={"x-forwarded-for": f"10.2.0.{i}"},
)
)
ghost_delays = _failed_login_delays(events)
assert real_delays == ghost_delays
assert real_delays[:2] == [0.0, 0.0] and all(d > 0 for d in real_delays[2:])
# И «наблюдаемая» часть ответа тоже неотличима.
assert [r.status_code for r in real_responses] == [r.status_code for r in ghost_responses]
assert {r.json()["detail"] for r in real_responses} == {
r.json()["detail"] for r in ghost_responses
}
def test_disabled_account_throttled_like_wrong_password(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Отказ по «доступ закрыт» идёт тем же хвостом: счётчик, аудит, задержка.
Иначе быстрый 401 у заблокированной учётки отличал бы её от несуществующей.
"""
store.add_user(
"blocked", hash_password("Secret123!"), role="employee", access_state=AccessState.DISABLED
)
_throttle_settings(monkeypatch, threshold=1, max_delay_s=0.01)
events = _capture_events(monkeypatch)
for i in range(2):
# Пароль ВЕРНЫЙ — отказ приходит именно из ветки состояния доступа.
resp = client.post(
"/api/v1/auth/login",
json={"username": "blocked", "password": "Secret123!"},
headers={"x-forwarded-for": f"10.3.0.{i}"},
)
assert resp.status_code == 401
assert _failed_login_delays(events) == [0.0, pytest.approx(0.01)]
def test_failed_login_events_reach_audit_with_counter_state(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""DoD 3: неудачные входы видны в аудите — с ip/ua и состоянием счётчика.
Именно счётчик отличает распределённый перебор от россыпи случайных
одиночных неудач: в `user_events` иначе видно только «по одной неудаче с
каждого адреса», что само по себе не тревожно.
"""
store.add_user("audited", hash_password("Secret123!"), role="employee")
events = _capture_events(monkeypatch)
resp = client.post(
"/api/v1/auth/login",
json={"username": "audited", "password": "s3cret-typo"},
# Левый хоп подделан клиентом, правый добавлен Caddy — в аудит обязан
# попасть ПРАВЫЙ (`ratelimit._client_ip`, допущение «ровно один прокси»).
# Иначе злоумышленник переписывал бы себе ip в журнале одним заголовком.
headers={
"x-forwarded-for": "198.51.100.9, 203.0.113.7",
"user-agent": "probe/1.0",
},
)
assert resp.status_code == 401
(failed,) = (e for e in events if e["event_type"] == "login_failed")
assert failed["username"] == "audited"
assert failed["ip"] == "203.0.113.7"
assert failed["user_agent"] == "probe/1.0"
assert failed["path"] == "/api/v1/auth/login"
assert failed["payload"]["username_fails_in_window"] == 1
# Raw-пароль не утекает ни в поля события, ни в payload.
assert "s3cret-typo" not in str(failed)
# ---------------------------------------------------------------------------
# #2665 — настоящий потолок ТЕМПА проверок пароля + свободный событийный цикл
# ---------------------------------------------------------------------------
# Порог ТЕМПА пробы (тик/с) для #3343. Не «сколько раз», а «как часто»: `elapsed`
# под нагрузкой растёт, поэтому абсолютное «>= 10 тиков» заблокированный цикл
# наберёт за 3-5с — прежний ассерт слабел ровно в том сценарии, ради которого
# написан. Замеры (macOS, 2026-09-05, три прогона каждый):
# пул (`asyncio.to_thread`, как в проде) — 65.5 / 65.6 / 66.3 тик/с;
# bcrypt прямо в `async def` (`return verify_password(...)` в
# `verify_password_bounded`) — 1.0 тик/с (6 тиков за 5.74с; заметь: старый
# порог «>= 10» на чуть более медленной машине эти 10 тиков добрал бы).
# Порог — геометрическая середина: sqrt(65.5 * 1.0) ≈ 8, то есть запас ×8 в обе
# стороны. Общий раннер отъедает пропускную способность, но не порядок величины.
MIN_PROBE_TICKS_PER_S = 8.0
async def test_login_flood_capped_by_rate_while_api_stays_responsive(
store: _Store, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""Сто одновременных соединений не получают больше N попыток В СЕКУНДУ, и при
этом остальной API продолжает отвечать.
ОБА утверждения в одном тесте намеренно — по отдельности каждое зелено на
сломанной системе:
- только про темп: сегодняшний код (bcrypt прямо в `async def`) тоже
держит темп низким — ценой того, что весь API стоит;
- только про отзывчивость: `asyncio.to_thread` без потолка освобождает
цикл и одновременно РАЗГОНЯЕТ перебор (замер на проде: 3.6 → 16
проверок/с).
Убери любую половину правки — тест обязан покраснеть.
Проверяем ТЕМП, а не латентность: задержка из #2571 (`await asyncio.sleep`)
латентность растит, а темп не ограничивает вовсе — сто соединений отспят её
параллельно. Поэтому меряем ЧИСЛО состоявшихся bcrypt-сверок за секунду
непрерывного флуда, а не время одного ответа.
Каждый запрос идёт со СВОЕЙ парой (username, ip). Это худший случай для
защит #2571 и он же реалистичный: при credential stuffing ни лимит на
(username, IP), ни счётчик неудач на имя не срабатывают ни разу — с чужого
адреса и с новым именем бюджет всегда свежий. Значит меряем ровно новый
потолок, а не соседний лимитер.
"""
verify_s = 0.05
# Потолок = размер пула / время одной сверки. Значение берётся из ТОЙ ЖЕ
# настройки, что его задаёт, поэтому этот тест проверяет только МЕХАНИКУ
# (потолок работает и равен пулу), но НЕ величину дефолта: подними
# login_password_verify_workers — поднимется и ожидание, тест останется
# зелёным. Сам дефолт стережёт
# tests/test_password.py::test_verify_ceiling_defaults_stay_within_the_db_pool.
ceiling_per_s = config.settings.login_password_verify_workers / verify_s
patch_identity_sessions(monkeypatch, lambda: _FakeDB(store))
_capture_events(monkeypatch)
app = _build_test_app(store)
attempts: list[float] = []
verify_threads: set[int] = set()
def _slow_verify(plain: str, hashed: str) -> bool:
"""Стенд-двойник bcrypt: столько же БЛОКИРУЮЩЕГО времени, только меньше.
Блокирующий `time.sleep`, а не `await` — суть проблемы в том, что bcrypt
не отпускает поток; двойник с `await` проверял бы не то.
Заодно записывает ПОТОК исполнения: это и есть механизм выноса (#3343) —
величина дискретная, от загрузки раннера не зависящая.
"""
attempts.append(time.monotonic())
verify_threads.add(threading.get_ident())
time.sleep(verify_s)
return False
monkeypatch.setattr(password_mod, "verify_password", _slow_verify)
probe_latencies: list[float] = []
flood_over = asyncio.Event()
async def probe(client: httpx.AsyncClient) -> None:
"""Сторонний (не login) запрос раз в 10мс — детектор занятости цикла."""
while not flood_over.is_set():
t0 = time.monotonic()
await client.get("/api/v1/trade-in/dummy")
probe_latencies.append(time.monotonic() - t0)
await asyncio.sleep(0.01)
duration_s = 1.0
connections = 100
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="https://testserver"
) as client:
async def attacker(n: int) -> list[int]:
codes: list[int] = []
i = 0
while time.monotonic() < deadline:
i += 1
resp = await client.post(
"/api/v1/auth/login",
json={"username": f"spray{n}x{i}", "password": "guess"},
headers={"x-forwarded-for": f"10.{n % 250}.{i % 250}.7"},
)
codes.append(resp.status_code)
await asyncio.sleep(0.005)
return codes
started = time.monotonic()
deadline = started + duration_s
probe_task = asyncio.create_task(probe(client))
code_lists = await asyncio.gather(*(attacker(n) for n in range(connections)))
elapsed = time.monotonic() - started
flood_over.set()
await probe_task
codes = [c for lst in code_lists for c in lst]
attempts_per_s = len(attempts) / elapsed
probe_latencies.sort()
probe_ticks_per_s = len(probe_latencies) / elapsed
median_probe = probe_latencies[len(probe_latencies) // 2] if probe_latencies else float("nan")
worst_probe = probe_latencies[-1] if probe_latencies else float("nan")
# Печать ДО первого утверждения и МИМО перехвата: захваченный вывод зелёного
# теста pytest не показывает (CI гоняет `uv run pytest -q -rs`), а запас на
# общем раннере интересен ровно когда всё прошло. Выше assert'ов — намеренно:
# на сломанной системе иначе не осталось бы и диагностики.
with capsys.disabled():
print(
f"[#3343] проба {len(probe_latencies)} тиков за {elapsed:.2f}с = "
f"{probe_ticks_per_s:.1f} тик/с (порог {MIN_PROBE_TICKS_PER_S} тик/с), медиана "
f"{median_probe * 1000:.0f}мс, худший {worst_probe * 1000:.0f}мс; сверок "
f"{len(attempts)} = {attempts_per_s:.0f}/с при потолке {ceiling_per_s:.0f}/с"
)
# 1. Событийный цикл СВОБОДЕН всё это время. С bcrypt внутри `async def`
# сторонний запрос ждёт столько, сколько длится очередь сверок.
# Проверяется ПЕРВЫМ: если цикл занят, встаёт и сам флуд, и тогда
# остальные числа мерят не потолок, а паралич — их надо читать после
# этого вердикта, а не вместо него.
#
# Вердикт — про МЕХАНИЗМ, а не про секундомер (#3343). Раньше здесь стоял
# абсолютный порог латентности пробы (`худший < 500мс`, `медиана < 50мс`).
# На общем раннере он мерил не свойство кода, а свободен ли CPU у соседей:
# 03.09 деплой встал на «худший сторонний запрос 544мс» ровно на том
# коммите, который часом раньше прошёл на незанятой машине. Порог, который
# краснеет от чужой параллельной сборки, не отличает поломку от нагрузки,
# а красный обязан значить «значение неверно», иначе его начинают
# переспрашивать. Обе замены переживают любую загрузку:
# - ПОТОК, в котором исполнилась сверка, — величина дискретная. Вернись
# bcrypt в `async def` — здесь окажется поток цикла, и никакой простой
# раннера этого не замаскирует и не подделает;
# - ТЕМП тиков пробы: цикл с bcrypt внутри стоит практически всю
# секунду флуда (сверки идут подряд по 50мс), и проба не успевает
# почти никогда; при выносе в пул она просыпается раз в 10мс.
# Сверяется именно ТЕМП (тик/с), а не число тиков: `elapsed` под
# нагрузкой растёт, и абсолютный порог «>= 10 тиков» заблокированный
# цикл наберёт за 3-5с — то есть абсолютный ассерт слабеет ровно в
# целевом сценарии. Порог MIN_PROBE_TICKS_PER_S — геометрическая
# середина между замерами, см. константу.
# Сами латентности остаются в сообщении как ДИАГНОСТИКА: числа полезны,
# когда тест красный, и не годятся в вердикт, пока машина общая.
assert verify_threads, "ни одной сверки пароля не состоялось — мерить нечего"
assert threading.get_ident() not in verify_threads, (
"сверка пароля исполнилась в потоке событийного цикла — bcrypt держит весь "
"API на всё время проверки (вынос в пул из #2665 отменён)"
)
assert probe_latencies, "проба не сделала ни одного запроса — цикл был занят"
assert probe_ticks_per_s >= MIN_PROBE_TICKS_PER_S, (
f"проба тикала {probe_ticks_per_s:.1f} раз/с при пороге {MIN_PROBE_TICKS_PER_S} "
f"({len(probe_latencies)} тиков за {elapsed:.2f}с, медиана "
f"{median_probe * 1000:.0f}мс, худший {worst_probe * 1000:.0f}мс) — цикл был занят"
)
# 2. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает
# потолок — до bcrypt их доехало не больше него (запас ×1.5 на планировщик).
max_attempts_per_s = ceiling_per_s * 1.5
offered_per_s = len(codes) / elapsed
# ПРЕДУСЛОВИЕ, и оно отделено от вердикта намеренно: «нагрузку создать не
# удалось» и «потолок не работает» — разные новости, и красный обязан их
# различать. Порог здесь ТОТ ЖЕ, с которым сверяется вердикт ниже, и это не
# совпадение: пока флуд предлагает меньше, следующее утверждение зелено даже
# на системе вовсе без потолка, то есть измерения нет.
#
# Раньше условием было `len(codes) > connections` — «каждое соединение
# успело сходить хотя бы дважды за секунду». Это мерило скорости РАННЕРА, а
# не нагрузки: на занятом первый круг из ста запросов сам съедал всю секунду,
# и сторож краснел неотличимо от настоящей поломки потолка (замер 2026-08-07:
# свободная машина — 0 падений из 20, ~800 ответов/с; под load average ~185 —
# 10 падений из 15, и в каждом флуд всё равно предлагал 55-80 запросов/с
# против порога 30/с, то есть мерить было на чем).
assert offered_per_s > max_attempts_per_s, (
f"нагрузку создать не удалось: флуд предложил {offered_per_s:.0f} запросов/с "
f"({len(codes)} за {elapsed:.2f}с) — не больше порога следующей проверки "
f"({max_attempts_per_s:.0f}/с), она прошла бы и без потолка. Это «раннер не "
f"потянул», НЕ «потолок сломан»"
)
assert attempts_per_s <= max_attempts_per_s, (
f"{attempts_per_s:.0f} сверок/с при потолке {ceiling_per_s:.0f}/с "
f"({len(attempts)} за {elapsed:.2f}с) — потолок темпа не работает"
)
# 3. Лишнее ОТКЛОНЯЕТСЯ, а не копится в очереди: очередь держала бы
# соединения к БД и выбрала бы пул (QueuePool 5+10).
assert codes.count(429) > codes.count(401), "избыток должен получать 429, а не ждать"
# 4. Потолок режет ТЕМП, а не обнуляет попытки: до bcrypt доезжает хоть
# что-то. Это утверждение ПРО АТАКУЮЩЕГО и ни слова не говорит о том,
# войдёт ли в это время живой человек — он не войдёт, пока слоты заняты
# флудом (#2714). Проверка «дверь открыта своим» — отдельным тестом ниже,
# и мерит она вход С ДРУГОГО КЛЮЧА, а не число попыток атакующего.
assert len(attempts) >= 2
# ---------------------------------------------------------------------------
# #2714 — потолок не должен бить по своим: доля слотов на один ключ
# ---------------------------------------------------------------------------
async def test_flood_from_one_ip_leaves_login_open_for_another_ip(
store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Пока один адрес непрерывно флудит, человек с ДРУГОГО адреса входит (#2714).
Именно это ломал потолок #2665 в исходном виде: слоты — общий котёл, флуд
занимал все четыре, и легитимный вход с ВЕРНЫМ паролем получал 429 столько
раз, сколько пытался (замер в issue: 20 попыток → 20×429, 0×200).
Тест мерит ровно заявленное — ВХОД С ДРУГОГО КЛЮЧА, а не латентность одного
запроса и не число попыток атакующего: с общим котлом «попытки атакующего
доезжают» и «свой войдёт» — разные утверждения, и первое зелено, когда
второе ложно.
Число попыток пробы = `login_rate_limit`, и это не подгонка: столько входов
по паре (имя, IP) вообще разрешено за окно соседним `_LOGIN_LIMITER`.
Просить больше значило бы мерить ЕГО 429 вместо потолка сверок — то есть
получить красный тест на исправном коде.
Стенд БЕЗ `RateLimitMiddleware` (его в тестовом приложении нет), поэтому
флуд здесь плотнее, чем один адрес может выдать на проде (там его режут
300 запросов за 60с). Так и задумано: проверяем худший случай.
"""
verify_s = 0.05
flood_ip = "203.0.113.66"
legit_ip = "198.51.100.10"
legit_password = "Secret123!"
patch_identity_sessions(monkeypatch, lambda: _FakeDB(store))
_capture_events(monkeypatch)
legit_hash = hash_password(legit_password)
store.add_user("realuser", legit_hash, role="employee")
app = _build_test_app(store)
def _slow_verify(plain: str, hashed: str) -> bool:
"""Двойник bcrypt: столько же БЛОКИРУЮЩЕГО времени, только меньше.
Вердикт настоящий (а не всегда-False, как в тесте про темп выше) — без
него легитимный вход не дошёл бы до 200 и мерить было бы нечего.
"""
time.sleep(verify_s)
return hashed == legit_hash and plain == legit_password
monkeypatch.setattr(password_mod, "verify_password", _slow_verify)
flood_codes: list[int] = []
flood_over = asyncio.Event()
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="https://testserver"
) as client:
async def flooder(n: int) -> None:
i = 0
while not flood_over.is_set():
i += 1
# Своё имя на каждую попытку — иначе флуд упрётся в
# `_LOGIN_LIMITER` (5 на пару имя+IP) и до потолка сверок не
# доедет вовсе: тест стал бы зелёным, ничего не проверив.
resp = await client.post(
"/api/v1/auth/login",
json={"username": f"nosuchuser{n}x{i}", "password": "guess"},
headers={"x-forwarded-for": flood_ip},
)
flood_codes.append(resp.status_code)
floods = [asyncio.create_task(flooder(n)) for n in range(8)]
try:
# Ждём ДОКАЗАННОГО насыщения: 429 у атакующего = слоты кончились.
# Без этого условия проба могла бы пройти по пустой очереди и тест
# был бы зелёным на сломанном коде.
saturation_deadline = time.monotonic() + 10.0
while flood_codes.count(429) < 4:
assert time.monotonic() < saturation_deadline, (
f"флуд не насытил слоты за 10с ({len(flood_codes)} ответов, "
f"429: {flood_codes.count(429)}) — мерить справедливость не на чем"
)
await asyncio.sleep(0.01)
probe_codes: list[int] = []
for _ in range(config.settings.login_rate_limit):
resp = await client.post(
"/api/v1/auth/login",
json={"username": "realuser", "password": legit_password},
headers={"x-forwarded-for": legit_ip},
)
probe_codes.append(resp.status_code)
finally:
flood_over.set()
await asyncio.gather(*floods)
assert probe_codes.count(200) == len(probe_codes), (
f"легитимный вход с {legit_ip} во время флуда с {flood_ip}: "
f"200={probe_codes.count(200)}, 429={probe_codes.count(429)}, "
f"401={probe_codes.count(401)} — потолок бьёт по своим (#2714)"
)
# ---------------------------------------------------------------------------
# #2715 — отказ по насыщению: до похода в БД и со следом, который не выселяет лог
# ---------------------------------------------------------------------------
def _saturate_verify_slots(monkeypatch: pytest.MonkeyPatch) -> None:
"""Слоты сверки заняты — снаружи ровно то же, что живой флуд, но без гонок.
Именно счётчик, а не мок `verify_password_bounded`: проверяем настоящий
предикат отказа (`verify_slots_saturated` читает этот же глобал), а не
собственную заглушку.
"""
monkeypatch.setattr(password_mod, "_verify_inflight", 999)
def test_saturated_login_answers_before_touching_the_registry(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Под насыщением отказ приходит ДО выборки пользователя (#2715).
Гейт стоял после SELECT'а, и каждый заведомо отклоняемый запрос всё равно
брал соединение из пула — тогда, когда система уже перегружена. Хуже того,
эта выборка оставалась ЕДИНСТВЕННОЙ работой на пути отказа: bcrypt, ровняющий
время ответа для существующего и несуществующего имени, до отказанного
запроса не доходит вовсе, так что разницу маскировать было нечем.
Мерим не тайминг (в CI флейкует), а сам факт похода в реестр — и заодно
побайтовую одинаковость ответа для живого и выдуманного имени.
"""
store.add_user("alice", hash_password("Secret123!"), role="employee")
_capture_events(monkeypatch)
lookups: list[str] = []
real_lookup = auth_router.get_user_by_username
def _spy(db: Any, username: str) -> Any:
lookups.append(username)
return real_lookup(db, username)
monkeypatch.setattr(auth_router, "get_user_by_username", _spy)
_saturate_verify_slots(monkeypatch)
bodies = []
for name in ("alice", "ghost"):
resp = client.post(
"/api/v1/auth/login",
json={"username": name, "password": "x"},
headers={"x-forwarded-for": "203.0.113.5"},
)
assert resp.status_code == 429, resp.text
assert resp.headers["Retry-After"] == "1"
bodies.append(resp.text)
assert lookups == [], f"под насыщением всё-таки сходили в реестр: {lookups}"
# Существующее и несуществующее имя — неразличимы (#2571 на этом пути тоже).
assert bodies[0] == bodies[1]
def test_key_share_alone_also_answers_before_the_registry(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Долю на ключ предчек проверяет ТОЖЕ — и это главный случай, а не запасной.
Соседний тест занимает ОБЩИЙ счётчик, а `or` в `verify_slots_saturated`
коротит на первой половине: выброси вторую — и тот тест останется зелёным.
Между тем при флуде с ОДНОГО адреса (#2714) общий потолок не выбирается
вовсе, первой упирается именно доля, и без неё в базу ходили бы почти все
отклонённые запросы.
"""
store.add_user("alice", hash_password("Secret123!"), role="employee")
_capture_events(monkeypatch)
lookups: list[str] = []
real_lookup = auth_router.get_user_by_username
def _spy(db: Any, username: str) -> Any:
lookups.append(username)
return real_lookup(db, username)
monkeypatch.setattr(auth_router, "get_user_by_username", _spy)
# Общий котёл (4) НЕ выбран: занято 2 из 4, и оба — одним адресом. Это ровно
# его доля (`_per_key_slot_cap` = 4 // 2), больше ему не дают.
monkeypatch.setattr(password_mod, "_verify_inflight", 2)
monkeypatch.setattr(password_mod, "_verify_inflight_by_key", {"203.0.113.5": 2})
assert password_mod._per_key_slot_cap() == 2 # исходные условия теста
flooder = client.post(
"/api/v1/auth/login",
json={"username": "alice", "password": "x"},
headers={"x-forwarded-for": "203.0.113.5"},
)
assert flooder.status_code == 429, flooder.text
assert lookups == [], f"доля исчерпана, а в реестр всё-таки сходили: {lookups}"
# И тут же — доказательство, что предчек не отказывает всем подряд: с
# ДРУГОГО адреса свободные слоты есть, запрос идёт дальше, в реестр.
other = client.post(
"/api/v1/auth/login",
json={"username": "alice", "password": "wrong"},
headers={"x-forwarded-for": "198.51.100.10"},
)
assert other.status_code == 401, other.text
assert lookups == ["alice"]
def test_saturation_is_reported_once_per_window_and_lands_in_audit(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch, caplog: Any
) -> None:
"""Двадцать отказов — одна запись в лог и одно событие в аудит, со счётчиком.
Строка на каждый отказ делила с остальным бэкендом `json-file max-size 20m ×
max-file 3`: при флуде в сотни запросов в секунду 60 МБ прокручиваются за
минуты и выселяют ВСЕ остальные логи ровно во время атаки. Поэтому окно.
А событие в `user_events` — потому что до #2715 инцидент не оставлял в
аудите ни строчки: событие неудачного входа тут не пишется намеренно
(пароль не проверялся, и трата бюджета неудач дала бы блокировку чужой
учётки насыщением) — значит нужен отдельный тип события, и он обязан
появляться независимо от того, ротировался лог или нет.
"""
# ЛИТЕРАЛ, а не арифметика от настройки: окно — компромисс «видно вовремя»
# против «не выселяет лог», и подъём его до минут прячет атаку целиком.
assert auth_router._SATURATION_REPORT_WINDOW_S == 1.0
events = _capture_events(monkeypatch)
_saturate_verify_slots(monkeypatch)
# Окно на весь тест — иначе медленный CI разбил бы 20 запросов на два окна
# и число записей стало бы функцией скорости раннера.
monkeypatch.setattr(auth_router, "_SATURATION_REPORT_WINDOW_S", 60.0)
caplog.set_level(logging.WARNING, logger="app.api.v1.auth")
for i in range(20):
resp = client.post(
"/api/v1/auth/login",
json={"username": f"ghost{i}", "password": "x"},
headers={"x-forwarded-for": "203.0.113.5"},
)
assert resp.status_code == 429, resp.text
lines = [r for r in caplog.records if "saturated" in r.getMessage()]
assert len(lines) == 1, f"20 отказов дали {len(lines)} строк в логе — агрегации нет"
# ERROR, а не WARNING: бэкенд поднят с LoggingIntegration(event_level=ERROR)
# (app/main.py), и только с ERROR запись становится событием GlitchTip.
# Понижение уровня выключило бы канал молча — прецедент #2674.
assert lines[0].levelno == logging.ERROR
saturated = [e for e in events if e["event_type"] == "login_verify_saturated"]
assert len(saturated) == 1, saturated
# Первый отказ отчитывается сразу (одиночная аномалия обязана быть видна),
# поэтому в первой записи он один — накопленное придёт следующей.
assert saturated[0]["payload"] == {"rejected": 1, "since_prev_s": None}
assert saturated[0]["ip"] == "203.0.113.5"
# Имя не пишем: отказ случился ДО того, как мы на него посмотрели, а запись
# присланного дала бы атакующему аудит-строки с любым именем на выбор.
assert saturated[0]["username"] == ""
# Бюджет неудач по имени не тронут — иначе насыщением блокируют чужой вход.
assert [e for e in events if e["event_type"] == "login_failed"] == []
assert not auth_router._USERNAME_FAIL_LIMITER._hits
# Окно прошло — следующий отказ приносит НАКОПЛЕННОЕ, а не единицу.
monkeypatch.setattr(auth_router, "_saturation_reported_at", time.monotonic() - 61.0)
resp = client.post(
"/api/v1/auth/login",
json={"username": "ghost-last", "password": "x"},
headers={"x-forwarded-for": "203.0.113.5"},
)
assert resp.status_code == 429
saturated = [e for e in events if e["event_type"] == "login_verify_saturated"]
assert len(saturated) == 2
assert saturated[1]["payload"]["rejected"] == 20, "счётчик за окно потерян"
# Без этого числа 20 отказов читались бы как «20 за секунду», хотя копились
# они минуту: хвост уезжает в запись, датированную моментом СЛЕДУЮЩЕГО
# отказа и подписанную ЕГО адресом — возможно, случайного соседа по NAT.
assert saturated[1]["payload"]["since_prev_s"] == pytest.approx(61.0, abs=1.0)
# ---------------------------------------------------------------------------
# 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")
# Роут зарегистрирован (см. _build_test_app), поэтому 403 приходит именно от
# admin-гейта rbac_guard'а: role=employee != admin. Не 404 — иначе «any
# non-2xx» маскировал бы опечатку в guard'е; и не 200 — иначе гейт не сработал.
# Один статус этого не доказывает: scope-check (deny-список employee) отвечает
# тем же 403, поэтому сверяем ТЕКСТ — 'admin only' пишет только admin-гейт,
# scope-ветка пишет 'forbidden for role' (app/core/rbac.py).
assert denied.status_code == 403
assert denied.json()["detail"] == "admin only", denied.text
# ---------------------------------------------------------------------------
# 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