All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 3m8s
Бэкенд поднят с LoggingIntegration(level=INFO, event_level=ERROR) (app/main.py): событием GlitchTip запись становится ровно с ERROR, а WARNING остаётся строкой в docker-логе, которая умирает с ротацией и редеплоем — то есть ровно тем следом, на бесполезность которого жалуется пункт 1 issue. Прецедент цены известен (#2674): монитор писал WARNING про протухшие куки, событий было ноль. Спама не будет: запись не чаще раза в окно (1с), все группируются в один issue. Получателей у проекта по-прежнему нет (#2673) — событие будет видно в интерфейсе и никому не уйдёт; это сказано в docstring, а не подразумевается. Уровень закреплён тестом: понижение до WARNING выключило бы канал молча. Refs #2715
1547 lines
79 KiB
Python
1547 lines
79 KiB
Python
"""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 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}
|
||
|
||
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", 0.0)
|
||
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 — настоящий потолок ТЕМПА проверок пароля + свободный событийный цикл
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_login_flood_capped_by_rate_while_api_stays_responsive(
|
||
store: _Store, monkeypatch: pytest.MonkeyPatch
|
||
) -> 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] = []
|
||
|
||
def _slow_verify(plain: str, hashed: str) -> bool:
|
||
"""Стенд-двойник bcrypt: столько же БЛОКИРУЮЩЕГО времени, только меньше.
|
||
|
||
Блокирующий `time.sleep`, а не `await` — суть проблемы в том, что bcrypt
|
||
не отпускает поток; двойник с `await` проверял бы не то.
|
||
"""
|
||
attempts.append(time.monotonic())
|
||
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
|
||
|
||
# 1. Событийный цикл СВОБОДЕН всё это время. С bcrypt внутри `async def`
|
||
# сторонний запрос ждёт столько, сколько длится очередь сверок.
|
||
# Проверяется ПЕРВЫМ: если цикл занят, встаёт и сам флуд, и тогда
|
||
# остальные числа мерят не потолок, а паралич — их надо читать после
|
||
# этого вердикта, а не вместо него.
|
||
assert probe_latencies, "проба не сделала ни одного запроса"
|
||
probe_latencies.sort()
|
||
assert (
|
||
probe_latencies[-1] < 0.5
|
||
), f"худший сторонний запрос {probe_latencies[-1] * 1000:.0f}мс — API встаёт под флудом входа"
|
||
median_probe = probe_latencies[len(probe_latencies) // 2]
|
||
assert median_probe < verify_s, (
|
||
f"медиана стороннего запроса {median_probe * 1000:.0f}мс ≥ времени одной "
|
||
f"сверки — цикл занят проверкой пароля, API стоит"
|
||
)
|
||
# Мало проб за секунду — тоже занятый цикл: проба просыпается раз в 10мс.
|
||
assert (
|
||
len(probe_latencies) >= 10
|
||
), f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят"
|
||
|
||
# 2. ТЕМП ограничен. Флуд предлагал тысячи попыток в секунду — до bcrypt их
|
||
# доехало не больше потолка (запас ×1.5 на планировщик).
|
||
assert len(codes) > connections, (
|
||
"флуд не состоялся: на каждое соединение вышло не больше одного ответа — "
|
||
"мерить потолок не на чем"
|
||
)
|
||
assert attempts_per_s <= ceiling_per_s * 1.5, (
|
||
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_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}
|
||
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}, "счётчик за окно потерян"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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
|