gendesign/tradein-mvp/backend/tests/test_auth_api.py
bot-backend 0835266516
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
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 1m19s
feat(tradein/auth): auth-core — login/logout, sessions, dual-mode rbac (#2552)
Foundation для эпика #2549: session-cookie auth поверх legacy Caddy
trusted-header. app.services.auth_session — CRUD для tradein_sessions
(create/get/revoke) + get_user_by_username для password-логина; opaque
secrets.token_urlsafe токены, sliding last_seen_at/expires_at refresh
(не чаще раза в 5 минут).

POST /api/v1/auth/login проверяет password_hash (bcrypt) через
app.core.password, ставит httponly+secure cookie, пишет
login_success/login_failed в user_events; per-username+IP rate-limit
(SlidingWindowLimiter) отдельно от общего RateLimitMiddleware. POST
/logout ревокает сессию и чистит cookie. Оба пути exempt из rbac_guard's
auth-required gate (иначе логин сам себя не пропустил бы).

rbac_guard теперь dual-mode: session-cookie резолвится первым (DB-роль
employee/manager/admin -> paths как у pilot/+team/admin), fallback на
legacy X-Authenticated-User + roles.yaml БЕЗ ИЗМЕНЕНИЙ когда auth_mode
== "dual"; auth_mode == "db_only" отключает legacy header полностью.
Резолвленный сессией username инжектится в ASGI scope headers (до
call_next) — RequestAuditMiddleware и downstream route-хендлеры видят
его прозрачно; RateLimitMiddleware (внешний относительно rbac_guard)
для session-запросов лимитирует по IP, не по username — документированный
trade-off, не регрессия.

GET /me — session-first: валидная cookie отдаёт scope из tradein_users
без похода в roles.yaml; без cookie — прежний legacy путь. session_secret
остаётся опциональным (opaque-токены не требуют подписи) — пустое
значение только logger.warning на старте, не startup-fail.

Полный набор тестов (tests/test_rbac.py, test_internal_auth_secret.py,
test_account_quota.py) проходит без правок — regression-safe.
2026-07-30 10:48:28 +03:00

424 lines
16 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 `tradein_users`/`tradein_sessions`:
- `app.core.rbac.SessionLocal` is monkeypatched (rbac_guard opens its own session,
it's middleware — no FastAPI DI available there).
- `app.core.db.get_db` is overridden via `app.dependency_overrides` (auth.py /
me.py use `Depends(get_db)`, the idiomatic FastAPI-testable path).
Both point at the SAME `_Store` instance per test, so a session created by POST
/login is immediately visible to rbac_guard's own DB round trip on the next request.
"""
from __future__ import annotations
import os
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from fastapi import FastAPI
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 config
from app.core.db import get_db
from app.core.password import hash_password
from app.core.rbac import rbac_guard
# ---------------------------------------------------------------------------
# Fake DB backing tradein_users / tradein_sessions
# ---------------------------------------------------------------------------
class _Store:
def __init__(self) -> None:
self.users: dict[str, dict[str, Any]] = {}
self.sessions: dict[str, dict[str, Any]] = {}
self._next_id = 1
def add_user(
self,
username: str,
password_hash: str | None,
*,
role: str = "employee",
is_active: bool = True,
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,
"is_active": is_active,
"display_name": display_name,
"org_name": org_name,
"email": email,
}
return uid
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 {}
if "INSERT INTO tradein_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 "UPDATE tradein_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 "DELETE FROM tradein_sessions WHERE token" in sql:
self.store.sessions.pop(p["token"], None)
return SimpleNamespace(fetchone=lambda: None)
if "DELETE FROM tradein_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 "FROM tradein_sessions s" in sql and "JOIN tradein_users u" in sql:
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)
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"],
is_active=user["is_active"],
)
return SimpleNamespace(fetchone=lambda: row)
if "FROM tradein_users" in sql:
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}
def _override_get_db(): # generator dependency — matches app.core.db.get_db shape
yield _FakeDB(store)
app.dependency_overrides[get_db] = _override_get_db
return app
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
auth_mod.reset_cache_for_tests()
auth_router._LOGIN_LIMITER._hits.clear()
monkeypatch.setattr(config.settings, "auth_mode", "dual")
@pytest.fixture
def store() -> _Store:
return _Store()
@pytest.fixture
def client(store: _Store, monkeypatch: pytest.MonkeyPatch) -> TestClient:
monkeypatch.setattr("app.core.rbac.SessionLocal", 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", is_active=False)
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_rate_limit_429(client: TestClient, store: _Store) -> None:
store.add_user("dave", hash_password("Secret123!"), role="employee")
limit = config.settings.login_rate_limit
for _ in range(limit):
resp = client.post("/api/v1/auth/login", json={"username": "dave", "password": "wrong"})
assert resp.status_code == 401
resp = client.post("/api/v1/auth/login", json={"username": "dave", "password": "wrong"})
assert resp.status_code == 429
assert "Retry-After" in resp.headers
# ---------------------------------------------------------------------------
# POST /logout
# ---------------------------------------------------------------------------
def test_logout_revokes_session_and_clears_cookie(client: TestClient, store: _Store) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
login_resp = client.post(
"/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"}
)
token = login_resp.cookies[config.settings.session_cookie_name]
assert token in store.sessions
logout_resp = client.post("/api/v1/auth/logout")
assert logout_resp.status_code == 200
assert logout_resp.json() == {"ok": True}
assert token not in store.sessions
# ---------------------------------------------------------------------------
# GET /me — session-first + dual-mode legacy fallback
# ---------------------------------------------------------------------------
def test_me_with_session_cookie_returns_db_role(client: TestClient, store: _Store) -> None:
store.add_user(
"alice",
hash_password("Secret123!"),
role="employee",
display_name="Алиса",
org_name="ООО Ромашка",
email="alice@romashka.ru",
)
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
resp = client.get("/api/v1/me")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["username"] == "alice"
assert body["role"] == "employee"
assert "/trade-in/**" in body["allowed_paths"]
assert "/admin/**" in body["deny_paths"]
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
assert "/api/v1/team/**" in resp.json()["allowed_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")
assert denied.status_code in (401, 403, 404)