"""Tests for app.services.auth_session — session CRUD + DB-role scope mapping (#2552). Coverage: - create_session: INSERT with CAST(...) (never `:x::type`), commit, unique tokens. - get_session_user: valid/expired/inactive/missing-row + sliding refresh (only when last_seen_at is stale, best-effort — a refresh failure still returns the user). - get_user_by_username: found/not-found. - revoke_session / revoke_user_sessions: DELETE + commit. - get_db_role_scope: employee/manager/admin/unknown mapping. All functions here take `db: Session` as a plain argument (no SessionLocal() opened internally) — unit tests just pass a hand-rolled fake, mirroring the `_FakeSession` pattern from tests/test_user_events.py but adapted for `.fetchone()`-based reads. """ from __future__ import annotations import os import re 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") from app.services import auth_session as svc # --------------------------------------------------------------------------- # Fake DB session # --------------------------------------------------------------------------- class _FakeDB: """Minimal `Session` stand-in: queued `.fetchone()` results per `execute()` call, in call order. `execute()` beyond the queue returns a result with `fetchone() -> None`. Set `.raise_on_call = N` to make the Nth `execute()` (1-indexed) raise. """ def __init__(self, rows: list[Any] | None = None) -> None: self._rows: list[Any] = list(rows or []) self.executed: list[tuple[str, dict[str, Any] | None]] = [] self.committed = 0 self.rolled_back = 0 self.raise_on_call: int | None = None def execute(self, stmt: object, params: dict[str, Any] | None = None) -> SimpleNamespace: call_no = len(self.executed) + 1 self.executed.append((str(stmt), params)) if self.raise_on_call == call_no: raise RuntimeError("simulated DB failure") row = self._rows.pop(0) if self._rows else None return SimpleNamespace(fetchone=lambda: row) def commit(self) -> None: self.committed += 1 def rollback(self) -> None: self.rolled_back += 1 def _session_row( *, user_id: int = 1, expires_at: datetime | None = None, last_seen_at: datetime | None = None, username: str = "alice", role: str = "employee", is_active: bool = True, ) -> SimpleNamespace: now = datetime.now(UTC) return SimpleNamespace( user_id=user_id, expires_at=expires_at if expires_at is not None else now + timedelta(hours=1), last_seen_at=last_seen_at if last_seen_at is not None else now, username=username, role=role, display_name="Alice A.", org_name="Org LLC", email="alice@example.com", is_active=is_active, ) def _user_row( *, user_id: int = 1, username: str = "alice", password_hash: str | None = "hash", role: str = "employee", is_active: bool = True, ) -> SimpleNamespace: return SimpleNamespace( id=user_id, username=username, password_hash=password_hash, role=role, is_active=is_active, display_name="Alice A.", org_name="Org LLC", email="alice@example.com", ) # --------------------------------------------------------------------------- # create_session # --------------------------------------------------------------------------- def test_create_session_inserts_and_commits() -> None: db = _FakeDB() token = svc.create_session(db, user_id=42, ip="1.2.3.4", user_agent="pytest") assert db.committed == 1 assert len(db.executed) == 1 sql, params = db.executed[0] assert "INSERT INTO tradein_sessions" in sql assert params is not None assert params["user_id"] == 42 assert params["ip"] == "1.2.3.4" assert params["user_agent"] == "pytest" assert params["token"] == token assert isinstance(token, str) assert len(token) >= 32 def test_create_session_cast_not_doublecolon() -> None: db = _FakeDB() svc.create_session(db, user_id=1) sql, _ = db.executed[0] assert not re.search(r":\w+::\w", sql) assert "CAST(:ttl_hours AS integer)" in sql assert "CAST(:ip AS inet)" in sql def test_create_session_tokens_are_unique() -> None: db = _FakeDB() t1 = svc.create_session(db, user_id=1) t2 = svc.create_session(db, user_id=1) assert t1 != t2 # --------------------------------------------------------------------------- # get_session_user # --------------------------------------------------------------------------- def test_get_session_user_no_token_returns_none() -> None: db = _FakeDB() assert svc.get_session_user(db, "") is None assert db.executed == [] def test_get_session_user_missing_row_returns_none() -> None: db = _FakeDB(rows=[None]) assert svc.get_session_user(db, "tok") is None sql, params = db.executed[0] assert "FROM tradein_sessions s" in sql assert "JOIN tradein_users u" in sql assert params == {"token": "tok"} def test_get_session_user_expired_returns_none() -> None: now = datetime.now(UTC) db = _FakeDB(rows=[_session_row(expires_at=now - timedelta(minutes=1))]) assert svc.get_session_user(db, "tok") is None # Никакого sliding-refresh UPDATE для невалидной сессии. assert len(db.executed) == 1 def test_get_session_user_inactive_returns_none() -> None: db = _FakeDB(rows=[_session_row(is_active=False)]) assert svc.get_session_user(db, "tok") is None assert len(db.executed) == 1 def test_get_session_user_valid_recent_no_refresh() -> None: """last_seen_at свежий (<5 мин) — sliding refresh НЕ триггерится.""" now = datetime.now(UTC) db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=1))]) result = svc.get_session_user(db, "tok") assert result is not None assert result["username"] == "alice" assert result["role"] == "employee" assert result["display_name"] == "Alice A." assert result["org_name"] == "Org LLC" assert result["email"] == "alice@example.com" assert result["user_id"] == 1 # Только 1 execute (SELECT) — никакого UPDATE. assert len(db.executed) == 1 assert db.committed == 0 def test_get_session_user_stale_last_seen_triggers_refresh() -> None: """last_seen_at старше 5 минут — один UPDATE (sliding refresh) + commit.""" now = datetime.now(UTC) db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=10))]) result = svc.get_session_user(db, "tok") assert result is not None assert len(db.executed) == 2 update_sql, update_params = db.executed[1] assert "UPDATE tradein_sessions" in update_sql assert "SET last_seen_at" in update_sql assert not re.search(r":\w+::\w", update_sql) assert "CAST(:ttl_hours AS integer)" in update_sql assert update_params == {"ttl_hours": 720, "token": "tok"} assert db.committed == 1 def test_get_session_user_refresh_failure_is_swallowed() -> None: """Sliding-refresh UPDATE падает — всё равно возвращаем валидного юзера (best-effort refresh, не часть решения "валидна ли сессия").""" now = datetime.now(UTC) db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=10))]) db.raise_on_call = 2 result = svc.get_session_user(db, "tok") assert result is not None assert result["username"] == "alice" assert db.rolled_back == 1 assert db.committed == 0 # --------------------------------------------------------------------------- # get_user_by_username # --------------------------------------------------------------------------- def test_get_user_by_username_found() -> None: db = _FakeDB(rows=[_user_row()]) user = svc.get_user_by_username(db, "alice") assert user is not None assert user["username"] == "alice" assert user["password_hash"] == "hash" assert user["role"] == "employee" assert user["is_active"] is True sql, params = db.executed[0] assert "FROM tradein_users" in sql assert params == {"username": "alice"} def test_get_user_by_username_not_found() -> None: db = _FakeDB(rows=[None]) assert svc.get_user_by_username(db, "ghost") is None # --------------------------------------------------------------------------- # revoke_session / revoke_user_sessions # --------------------------------------------------------------------------- def test_revoke_session_deletes_and_commits() -> None: db = _FakeDB() svc.revoke_session(db, "tok") assert db.committed == 1 sql, params = db.executed[0] assert "DELETE FROM tradein_sessions" in sql assert "token" in sql assert params == {"token": "tok"} def test_revoke_user_sessions_deletes_and_commits() -> None: db = _FakeDB() svc.revoke_user_sessions(db, 7) assert db.committed == 1 sql, params = db.executed[0] assert "DELETE FROM tradein_sessions" in sql assert "user_id" in sql assert params == {"user_id": 7} # --------------------------------------------------------------------------- # get_db_role_scope # --------------------------------------------------------------------------- def test_get_db_role_scope_employee_matches_legacy_pilot() -> None: paths, deny = svc.get_db_role_scope("employee") assert "/trade-in/**" in paths assert "/trade-in/api/v1/**" in paths assert "/admin/**" in deny assert "/api/v1/admin/**" in deny def test_get_db_role_scope_manager_adds_team_path() -> None: paths, deny = svc.get_db_role_scope("manager") assert "/trade-in/**" in paths assert "/api/v1/team/**" in paths assert "/admin/**" in deny def test_get_db_role_scope_admin_full_access() -> None: paths, deny = svc.get_db_role_scope("admin") assert paths == ["/**"] assert deny == [] def test_get_db_role_scope_unknown_role_denies_all() -> None: paths, deny = svc.get_db_role_scope("ghost") assert paths == [] assert deny == ["/**"]