gendesign/tradein-mvp/backend/tests/test_auth_api.py
bot-backend 7d154de1f7
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 2m50s
feat(tradein/auth): глобальный потолок попыток входа на имя пользователя (#2571)
Лимит на логине ключевался парой (username, IP), поэтому распределённый
перебор одного имени с тысячи адресов получал по 5 попыток с каждого
источника и не упирался ни во что. После снятия Caddy basic_auth с
/trade-in (#2558) POST /auth/login — единственная ручка, доступная из
интернета без кредов, так что дыра открыта прямо сейчас.

Поверх существующего per-IP лимита добавлен глобальный счётчик неудач
на ИМЯ, без IP в ключе. Превышение порога не блокирует учётку, а растит
задержку ответа (удвоение от 1с до потолка): блокировка по имени была бы
вектором отказа в обслуживании против конкретного человека — не зная
пароля, злоумышленник гарантированно выключал бы чужой вход.

Задержка применяется по ПРИСЛАННОМУ имени, без проверки его в реестре, и
из одного места — общего хвоста всех отказов по кредам. Иначе «быстрый
401» для несуществующего имени стал бы оракулом существования учётки, то
есть ровно той user-enumeration, от которой уже защищают одинаковый
generic-ответ и безусловный bcrypt.
2026-08-05 22:38:23 +05:00

1088 lines
51 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
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 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()
auth_router._USERNAME_FAIL_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
# ---------------------------------------------------------------------------
# #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
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_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_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)
# ---------------------------------------------------------------------------
# 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