"""Integration tests for #2554 team-management API — employees CRUD, quotas, history. Same pattern as `tests/test_auth_api.py`: real `rbac_guard` + real `auth.router` / `team.router` wired into an isolated FastAPI test app, with an in-memory fake DB (`_Store`/`_FakeDB`) dispatching on SQL text standing in for реестра людей / `account_quota_overrides` / `account_estimate_usage` / `user_events` / `trade_in_estimates`. Сессия РЕЕСТРА подменяется на самом низком уровне (`identity_store.SessionLocal` + `auth_db.auth_session`, см. `tests.support.identity_modes.patch_identity_sessions`), а `app.core.db.get_db` — через `app.dependency_overrides`. Поэтому и `identity_session()` (rbac_guard — middleware, FastAPI-DI там нет), и `Depends(get_identity_db)` (`current_team_actor`, все team-роуты) выполняются НАСТОЯЩИЕ, вместе со своим ветвлением по `settings.identity_store`. Все они смотрят в ОДИН `_Store` на тест — сессия из POST /login сразу видна и rbac_guard'у, и `current_team_actor`. ДВЕ СЕССИИ. В дефолтном режиме `get_identity_db` отдаёт ТОТ ЖЕ объект, что `get_db` (одна БД, одна транзакция — сегодняшний прод). В режиме `auth` это физически разные сессии, и `team.py` коммитит их отдельно (`if db is not identity_db`). Здесь это воспроизводится честно: в режиме `auth` реестр и продуктовые таблицы получают РАЗНЫЕ `_FakeDB` (общий `_Store` — как общий «кластер», но разные соединения). ⚠️ ЛОВУШКА FAKE-DB. `_FakeDB` диспатчит по ТЕКСТУ SQL, а эпик «единый вход» переименовывает таблицы (`tradein_users`/`tradein_sessions` → `users`/`sessions`) и меняет тип колонки состояния доступа (`is_active boolean` → `access_state text`). Литерал «tradein_users» в диспатчере означал бы, что при `IDENTITY_STORE=auth` ветка молча перестаёт матчиться, fake отдаёт пустоту, а тест остаётся ЗЕЛЁНЫМ на сломанном коде. Поэтому имена берутся из `sql_names()` (= `identity_schema()`, тот же словарь, что у продакшн-кода), а непонятый SQL падает `AssertionError`, а не возвращает пустой результат. Значение состояния доступа fake хранит СЫРЫМ (то, что реально лежало бы в колонке) и НЕ прогоняет через `identity_store.access_state_param()` — иначе инверсия этой функции прошла бы round-trip через fake незамеченной. """ from __future__ import annotations import os import re from datetime import UTC, datetime, timedelta from types import SimpleNamespace from typing import Any from uuid import uuid4 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 team as team_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 from app.services.identity_store import AccessState from tests.support.identity_modes import ( assert_insert_writes_access_state, assert_reads_access_state, assert_update_writes_access_state, column_value, patch_identity_sessions, sql_names, use_identity_mode, ) # --------------------------------------------------------------------------- # Fake DB backing реестр людей / sessions / quota / user_events # --------------------------------------------------------------------------- class _Store: def __init__(self) -> None: self.users: dict[str, dict[str, Any]] = {} # username -> user dict self.sessions: dict[str, dict[str, Any]] = {} self.quota_overrides: dict[str, dict[str, Any]] = {} self.usage: dict[tuple[str, str], int] = {} self.estimates: dict[str, dict[str, Any]] = {} # estimate_id -> result fields self.events: list[dict[str, Any]] = [] # user_events rows (history source) self.sql_log: list[str] = [] # весь SQL, доехавший до «БД» — см. тесты режимов self.commits: list[int] = [] # id() сессий, на которых вызывали commit() self._next_id = 1 self.query_count = 0 # db.execute() calls — N+1 regression guard (review PR #2563) def add_user( self, username: str, password_hash: str | None, *, role: str = "employee", manager_id: int | None = None, access_state: AccessState = AccessState.ACTIVE, display_name: str | None = None, org_name: str | None = None, email: str | None = None, created_at: datetime | None = None, ) -> int: uid = self._next_id self._next_id += 1 self.users[username] = { "id": uid, "username": username, "password_hash": password_hash, "role": role, "manager_id": manager_id, "display_name": display_name, "org_name": org_name, "email": email, # СЫРОЕ значение колонки текущего режима (boolean либо text). "access_state": column_value(access_state), "created_at": created_at or datetime.now(UTC), } 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_estimate_event( self, username: str, *, address: str | None = "ул. Ленина, 1", area_m2: str | None = "45", rooms: str | None = "2", estimate_id: str | None = None, median_price: int | None = None, confidence: str | None = None, n_analogs: int | None = None, created_at: datetime | None = None, ) -> None: eid = estimate_id or str(uuid4()) self.events.append( { "username": username, "event_type": "estimate_request", "estimate_id": eid, "payload": {"address": address, "area_m2": area_m2, "rooms": rooms}, "created_at": created_at or datetime.now(UTC), } ) if median_price is not None or confidence is not None or n_analogs is not None: self.estimates[eid] = { "median_price": median_price, "confidence": confidence, "n_analogs": n_analogs, } class _Result: """Minimal cursor-result stand-in: `.fetchone()`/`.fetchall()` (attribute-style Row) AND `.mappings().fetchone()`/`.all()` (dict-style RowMapping) — enough surface for the SQL used by app.api.v1.team / app.api.v1.auth / app.services.auth_session / app.services.account_quota.""" def __init__(self, rows: list[dict[str, Any]]) -> None: self._rows = rows def fetchone(self) -> SimpleNamespace | None: return SimpleNamespace(**self._rows[0]) if self._rows else None def fetchall(self) -> list[SimpleNamespace]: return [SimpleNamespace(**r) for r in self._rows] def mappings(self) -> _Mappings: return _Mappings(self._rows) class _Mappings: def __init__(self, rows: list[dict[str, Any]]) -> None: self._rows = rows def fetchone(self) -> dict[str, Any] | None: return dict(self._rows[0]) if self._rows else None def all(self) -> list[dict[str, Any]]: return [dict(r) for r in self._rows] 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: self.store.commits.append(id(self)) def rollback(self) -> None: pass def execute(self, stmt: object, params: dict[str, Any] | None = None) -> _Result: sql = str(stmt) p = params or {} s = self.store s.query_count += 1 s.sql_log.append(sql) # Имена таблиц/колонки берутся ИЗ КОДА (identity_schema), а не из # литералов — см. «ЛОВУШКА FAKE-DB» в модульном docstring. names = sql_names() # ---- сессии реестра ---- if f"INSERT INTO {names.sessions}" in sql: now = datetime.now(UTC) s.sessions[p["token"]] = { "user_id": p["user_id"], "expires_at": now + timedelta(hours=p["ttl_hours"]), "last_seen_at": now, } return _Result([]) if f"UPDATE {names.sessions}" in sql and "SET last_seen_at" in sql: sess = s.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 _Result([]) if f"DELETE FROM {names.sessions} WHERE token" in sql: s.sessions.pop(p["token"], None) return _Result([]) if f"DELETE FROM {names.sessions} WHERE user_id" in sql: uid = p["user_id"] for tok in [t for t, sess in s.sessions.items() if sess["user_id"] == uid]: del s.sessions[tok] return _Result([]) if f"FROM {names.sessions} s" in sql and f"JOIN {names.users} u" in sql: sess = s.sessions.get(p["token"]) if sess is None: return _Result([]) user = s.user_by_id(sess["user_id"]) if user is None: return _Result([]) # Колонка состояния приезжает под алиасом `access_state` в обоих # режимах (`u.<колонка> AS access_state`), значение — сырое. return _Result( [ { "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"], } ] ) # ---- реестр: login lookup (get_user_by_username) ---- # Дискриминатор — bind-параметр `:username` (у pre-check'а уникальности # ниже он называется `:u`), поэтому ветки не пересекаются ни в одном режиме. if f"FROM {names.users}" in sql and "WHERE username = :username" in sql: assert_reads_access_state(sql, names) user = s.users.get(p["username"]) return _Result([user] if user is not None else []) # ---- реестр: create ---- if f"INSERT INTO {names.users}" in sql: assert_insert_writes_access_state(sql, names) assert_reads_access_state(sql, names) # RETURNING отдаёт её же uid = s._next_id s._next_id += 1 created_at = datetime.now(UTC) row = { "id": uid, "username": p["username"], "password_hash": p["password_hash"], "role": "employee", "manager_id": p["manager_id"], "display_name": p["display_name"], "org_name": p["org_name"], "email": p["email"], # Ровно то, что код прислал параметром — БЕЗ нормализации. # Инверсия `access_state_param()` обязана доехать до ответа API # (`is_active`), а не раствориться в дублёре. "access_state": p["access_state"], "created_at": created_at, } s.users[p["username"]] = row return _Result([dict(row)]) # ---- реестр: manager_id validation ---- if f"FROM {names.users}" in sql and "role = 'manager'" in sql: user = s.user_by_id(p["id"]) match = user is not None and user["role"] == "manager" return _Result([{"id": user["id"]}] if match else []) # ---- реестр: list managed rows (has explicit ORDER BY) ---- # Две ветки реального кода: `role = 'employee'` (manager, либо admin с # ?manager_id=) и `role IN ('employee','manager')` (admin без фильтра — # ему нужны и менеджеры, иначе некому сбросить пароль, см. team.py). if ( f"FROM {names.users}" in sql and ("role = 'employee'" in sql or "role IN ('employee', 'manager')" in sql) and "ORDER BY created_at DESC" in sql ): assert_reads_access_state(sql, names) managed = ( ("employee", "manager") if "role IN ('employee', 'manager')" in sql else ("employee",) ) rows = [u for u in s.users.values() if u["role"] in managed] if "manager_id" in p: rows = [u for u in rows if u["manager_id"] == p["manager_id"]] # Mirrors real SQL `ORDER BY created_at DESC, id DESC` — `id` tiebreak # is REQUIRED for deterministic paging when created_at ties (follow-up # review PR #2563 п.1, bulk-seed #2557 inserts many rows in one tx). rows = sorted(rows, key=lambda u: (u["created_at"], u["id"]), reverse=True) offset, limit = p.get("offset", 0), p.get("limit", len(rows)) rows = rows[offset : offset + limit] return _Result( [ { "id": u["id"], "username": u["username"], "role": u["role"], "display_name": u["display_name"], "org_name": u["org_name"], "email": u["email"], "access_state": u["access_state"], "manager_id": u["manager_id"], "created_at": u["created_at"], } for u in rows ] ) # ---- реестр: fetch single managed row by id ---- if f"FROM {names.users}" in sql and ( "role = 'employee'" in sql or "role IN ('employee', 'manager')" in sql ): assert_reads_access_state(sql, names) managed = ( ("employee", "manager") if "role IN ('employee', 'manager')" in sql else ("employee",) ) user = s.user_by_id(p["id"]) if user is None or user["role"] not in managed: return _Result([]) return _Result( [ { "id": user["id"], "username": user["username"], "role": user["role"], "display_name": user["display_name"], "org_name": user["org_name"], "email": user["email"], "access_state": user["access_state"], "manager_id": user["manager_id"], "created_at": user["created_at"], } ] ) # ---- реестр: uniqueness pre-check ---- if sql.strip().startswith(f"SELECT id FROM {names.users} WHERE username"): user = s.users.get(p["u"]) return _Result([{"id": user["id"]}] if user is not None else []) # ---- реестр: update (PATCH) ---- if f"UPDATE {names.users}" in sql and "SET display_name = COALESCE" in sql: assert_update_writes_access_state(sql, names) user = s.user_by_id(p["id"]) assert user is not None if p.get("display_name") is not None: user["display_name"] = p["display_name"] if p.get("org_name") is not None: user["org_name"] = p["org_name"] if p.get("email") is not None: user["email"] = p["email"] # COALESCE(CAST(:access_state AS <тип>), <колонка>) — None означает # «поле не пришло в PATCH», значение записывается КАК ЕСТЬ (см. # комментарий про round-trip в INSERT выше). if p.get("access_state") is not None: user["access_state"] = p["access_state"] if p.get("password_hash") is not None: user["password_hash"] = p["password_hash"] return _Result([]) # ---- account_quota_overrides upsert: unlimited always reset to False, # note preserved (COALESCE) if a row already existed — mirrors real SQL. if "INSERT INTO account_quota_overrides" in sql: existing_override = s.quota_overrides.get(p["username"]) preserved_note = ( existing_override["note"] if existing_override is not None else None ) or p["note"] s.quota_overrides[p["username"]] = { "monthly_limit": p["monthly_limit"], "unlimited": False, "note": preserved_note, } return _Result([]) # ---- account_quota_overrides: batch (list_employees) ---- if "SELECT username, monthly_limit, unlimited" in sql: rows = [ {"username": u, "monthly_limit": ov["monthly_limit"], "unlimited": ov["unlimited"]} for u, ov in s.quota_overrides.items() if u in p["usernames"] ] return _Result(rows) if "SELECT unlimited FROM account_quota_overrides" in sql: override = s.quota_overrides.get(p["u"]) return _Result([{"unlimited": override["unlimited"]}] if override else []) if "SELECT monthly_limit FROM account_quota_overrides" in sql: override = s.quota_overrides.get(p["u"]) return _Result([{"monthly_limit": override["monthly_limit"]}] if override else []) # ---- account_estimate_usage: batch (list_employees) ---- if "SELECT username, used" in sql: rows = [ {"username": u, "used": used} for (u, period), used in s.usage.items() if u in p["usernames"] and period == p["period"] ] return _Result(rows) # ---- account_estimate_usage: single (account_quota.get_status) ---- if "SELECT used FROM account_estimate_usage" in sql: used = s.usage.get((p["u"], p["p"])) return _Result([{"used": used}] if used is not None else []) # ---- user_events + trade_in_estimates (history) ---- if "FROM user_events ue" in sql: matches = [ e for e in s.events if e["username"] == p["username"] and e["event_type"] == "estimate_request" ] matches.sort(key=lambda e: e["created_at"], reverse=True) page = matches[p["offset"] : p["offset"] + p["limit"]] rows = [] for e in page: extra = s.estimates.get(e["estimate_id"], {}) rows.append( { "estimate_id": e["estimate_id"], "address": e["payload"].get("address"), "area_m2": e["payload"].get("area_m2"), "rooms": e["payload"].get("rooms"), "median_price": extra.get("median_price"), "confidence": extra.get("confidence"), "n_analogs": extra.get("n_analogs"), "created_at": e["created_at"], } ) return _Result(rows) raise AssertionError(f"unhandled fake SQL in test_team_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(team_router.router, prefix="/api/v1/team", tags=["team"]) 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") # Каждый тест стартует в ДЕФОЛТНОМ режиме реестра (сегодняшний прод). use_identity_mode(monkeypatch, "tradein") # team.py / auth.py events go through schedule_event (own SessionLocal(), fire- # and-forget) — captured into a list instead of hitting a real DB. monkeypatch.setattr(team_router, "schedule_event", lambda **kw: _EVENTS.append(kw)) monkeypatch.setattr(auth_router, "schedule_event", lambda **kw: None) _EVENTS.clear() _EVENTS: list[dict[str, Any]] = [] @pytest.fixture def store() -> _Store: return _Store() @pytest.fixture def auth_store(store: _Store, monkeypatch: pytest.MonkeyPatch) -> _Store: """Тот же `store`, но реестр — БД `auth` (`users`/`sessions`, text-состояние). Запрашивать ПЕРЕД `client`: `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()` остаются настоящими, # включая инвариант «в дефолтном режиме это тот же объект, что у get_db». patch_identity_sessions(monkeypatch, lambda: _FakeDB(store)) # base_url=https:// — login sets a Secure cookie; see test_auth_api.py for why # a plain-http TestClient would silently drop it. return TestClient(_build_test_app(store), base_url="https://testserver") def _login(client: TestClient, username: str, password: str) -> None: resp = client.post("/api/v1/auth/login", json={"username": username, "password": password}) assert resp.status_code == 200, resp.text # --------------------------------------------------------------------------- # POST /employees — happy path + validation # --------------------------------------------------------------------------- def test_manager_creates_employee_forces_own_manager_id(client: TestClient, store: _Store) -> None: mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") _login(client, "mgr_a", "Secret123!") resp = client.post( "/api/v1/team/employees", json={"username": "emp_alice", "password": "Secret123!", "display_name": "Алиса"}, ) assert resp.status_code == 201, resp.text body = resp.json() assert body["username"] == "emp_alice" assert body["manager_id"] == mgr_id assert body["quota"]["limit"] > 0 assert store.users["emp_alice"]["password_hash"] is not None assert any(e["event_type"] == "employee_created" for e in _EVENTS) created_event = next(e for e in _EVENTS if e["event_type"] == "employee_created") # Пароль никогда не попадает в аудит-событие. assert "Secret123!" not in str(created_event) def test_admin_creates_employee_with_explicit_manager_id(client: TestClient, store: _Store) -> None: store.add_user("admin1", hash_password("Secret123!"), role="admin") mgr_id = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") _login(client, "admin1", "Secret123!") resp = client.post( "/api/v1/team/employees", json={"username": "emp_bob", "password": "Secret123!", "manager_id": mgr_id}, ) assert resp.status_code == 201, resp.text assert resp.json()["manager_id"] == mgr_id def test_admin_creates_employee_without_manager_id(client: TestClient, store: _Store) -> None: store.add_user("admin1", hash_password("Secret123!"), role="admin") _login(client, "admin1", "Secret123!") resp = client.post( "/api/v1/team/employees", json={"username": "emp_free", "password": "Secret123!"} ) assert resp.status_code == 201, resp.text assert resp.json()["manager_id"] is None def test_admin_create_employee_invalid_manager_id_422(client: TestClient, store: _Store) -> None: store.add_user("admin1", hash_password("Secret123!"), role="admin") _login(client, "admin1", "Secret123!") resp = client.post( "/api/v1/team/employees", json={"username": "emp_x", "password": "Secret123!", "manager_id": 999}, ) assert resp.status_code == 422 def test_create_employee_non_ascii_username_422(client: TestClient, store: _Store) -> None: store.add_user("mgr_a", hash_password("Secret123!"), role="manager") _login(client, "mgr_a", "Secret123!") resp = client.post( "/api/v1/team/employees", json={"username": "сотрудник", "password": "Secret123!"}, ) assert resp.status_code == 422 @pytest.mark.parametrize("username", ["admin\n", "user1\n"]) def test_create_employee_trailing_newline_username_422_not_500( client: TestClient, store: _Store, username: str ) -> None: """Deep-review seed #2564: Python `$` matches BEFORE a trailing newline (`re.match(r'...\\$', 'admin\\n')` → True), but Postgres `~` (CHECK tradein_users_username_ascii_ck, migration 193) does NOT — a username with a trailing "\\n" used to pass Pydantic validation and crash in the DB (500) instead of a clean 422. `_USERNAME_RE` now uses `\\Z`, matching Postgres `~` semantics exactly.""" store.add_user("mgr_a", hash_password("Secret123!"), role="manager") _login(client, "mgr_a", "Secret123!") resp = client.post( "/api/v1/team/employees", json={"username": username, "password": "Secret123!"}, ) assert resp.status_code == 422, resp.text def test_create_employee_duplicate_username_409(client: TestClient, store: _Store) -> None: store.add_user("mgr_a", hash_password("Secret123!"), role="manager") store.add_user("emp_dup", hash_password("Secret123!"), role="employee") _login(client, "mgr_a", "Secret123!") resp = client.post( "/api/v1/team/employees", json={"username": "emp_dup", "password": "Secret123!"}, ) assert resp.status_code == 409 def test_create_employee_no_session_401(client: TestClient) -> None: resp = client.post( "/api/v1/team/employees", json={"username": "emp_x", "password": "Secret123!"} ) assert resp.status_code == 401 def test_create_employee_employee_role_403(client: TestClient, store: _Store) -> None: store.add_user("emp_a", hash_password("Secret123!"), role="employee") _login(client, "emp_a", "Secret123!") resp = client.post( "/api/v1/team/employees", json={"username": "emp_x", "password": "Secret123!"} ) assert resp.status_code == 403 def test_employee_role_403_on_all_team_routes(client: TestClient, store: _Store) -> None: store.add_user("emp_a", hash_password("Secret123!"), role="employee") _login(client, "emp_a", "Secret123!") assert client.get("/api/v1/team/employees").status_code == 403 assert client.patch("/api/v1/team/employees/1", json={}).status_code == 403 assert client.get("/api/v1/team/employees/1/history").status_code == 403 # --------------------------------------------------------------------------- # Admin управляет менеджерами (инцидент 2026-07-31: kopylov/praktika — role # 'manager', сбросить им пароль через UI было нечем) # --------------------------------------------------------------------------- def test_admin_list_includes_managers(client: TestClient, store: _Store) -> None: store.add_user("admin1", hash_password("Secret123!"), role="admin") mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") store.add_user("emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id) _login(client, "admin1", "Secret123!") resp = client.get("/api/v1/team/employees") assert resp.status_code == 200 by_username = {e["username"]: e for e in resp.json()} # Менеджер виден; сам admin — нет (role='admin' не отдаётся никогда). assert set(by_username) == {"mgr_a", "emp_a"} assert by_username["mgr_a"]["role"] == "manager" assert by_username["emp_a"]["role"] == "employee" def test_admin_resets_manager_password_and_revokes_sessions( client: TestClient, store: _Store ) -> None: """Главный сценарий инцидента: admin выдаёт менеджеру новый пароль.""" store.add_user("admin1", hash_password("Secret123!"), role="admin") mgr_id = store.add_user("mgr_a", hash_password("OldSecret1!"), role="manager") # У менеджера есть живая сессия — после сброса она обязана умереть. _login(client, "mgr_a", "OldSecret1!") assert any(sess["user_id"] == mgr_id for sess in store.sessions.values()) client.cookies.clear() _login(client, "admin1", "Secret123!") resp = client.patch(f"/api/v1/team/employees/{mgr_id}", json={"new_password": "NewSecret1!"}) assert resp.status_code == 200 assert resp.json()["role"] == "manager" assert not any(sess["user_id"] == mgr_id for sess in store.sessions.values()) # Новый пароль реально работает, старый — нет. client.cookies.clear() assert ( client.post( "/api/v1/auth/login", json={"username": "mgr_a", "password": "OldSecret1!"} ).status_code == 401 ) assert ( client.post( "/api/v1/auth/login", json={"username": "mgr_a", "password": "NewSecret1!"} ).status_code == 200 ) def test_admin_blocks_manager(client: TestClient, store: _Store) -> None: store.add_user("admin1", hash_password("Secret123!"), role="admin") mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") _login(client, "admin1", "Secret123!") resp = client.patch(f"/api/v1/team/employees/{mgr_id}", json={"is_active": False}) assert resp.status_code == 200 assert resp.json()["is_active"] is False def test_manager_cannot_patch_another_manager_404(client: TestClient, store: _Store) -> None: """Расширение прав дано ТОЛЬКО admin'у — manager до чужой строки не достаёт.""" store.add_user("mgr_a", hash_password("Secret123!"), role="manager") mgr_b_id = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") _login(client, "mgr_a", "Secret123!") resp = client.patch(f"/api/v1/team/employees/{mgr_b_id}", json={"new_password": "Hacked123!"}) assert resp.status_code == 404 # И в списке чужого менеджера тоже нет. assert client.get("/api/v1/team/employees").json() == [] def test_admin_cannot_patch_admin_row_404(client: TestClient, store: _Store) -> None: """Инвариант отсутствия self-lockout: строки role='admin' недостижимы.""" admin_id = store.add_user("admin1", hash_password("Secret123!"), role="admin") other_admin_id = store.add_user("admin2", hash_password("Secret123!"), role="admin") _login(client, "admin1", "Secret123!") assert ( client.patch(f"/api/v1/team/employees/{admin_id}", json={"is_active": False}).status_code == 404 ) assert ( client.patch( f"/api/v1/team/employees/{other_admin_id}", json={"new_password": "Nope12345!"} ).status_code == 404 ) def test_manager_cannot_see_managers_in_own_list(client: TestClient, store: _Store) -> None: mgr_a = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") store.add_user("mgr_b", hash_password("Secret123!"), role="manager") store.add_user("emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_a) _login(client, "mgr_a", "Secret123!") rows = client.get("/api/v1/team/employees").json() assert {e["username"] for e in rows} == {"emp_a"} assert {e["role"] for e in rows} == {"employee"} # --------------------------------------------------------------------------- # Org isolation — manager A vs manager B # --------------------------------------------------------------------------- def test_manager_a_cannot_see_manager_b_employee(client: TestClient, store: _Store) -> None: mgr_a = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") mgr_b = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") store.add_user("emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_a) store.add_user("emp_b", hash_password("Secret123!"), role="employee", manager_id=mgr_b) _login(client, "mgr_a", "Secret123!") resp = client.get("/api/v1/team/employees") assert resp.status_code == 200 usernames = {e["username"] for e in resp.json()} assert usernames == {"emp_a"} def test_manager_a_patch_manager_b_employee_404(client: TestClient, store: _Store) -> None: mgr_a = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") mgr_b = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") emp_b_id = store.add_user( "emp_b", hash_password("Secret123!"), role="employee", manager_id=mgr_b ) assert mgr_a # used only to seed manager_id != mgr_b _login(client, "mgr_a", "Secret123!") resp = client.patch(f"/api/v1/team/employees/{emp_b_id}", json={"display_name": "hacked"}) assert resp.status_code == 404 def test_manager_a_history_manager_b_employee_404(client: TestClient, store: _Store) -> None: mgr_a = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") mgr_b = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") emp_b_id = store.add_user( "emp_b", hash_password("Secret123!"), role="employee", manager_id=mgr_b ) assert mgr_a _login(client, "mgr_a", "Secret123!") resp = client.get(f"/api/v1/team/employees/{emp_b_id}/history") assert resp.status_code == 404 def test_manager_post_with_foreign_manager_id_creates_under_self( client: TestClient, store: _Store ) -> None: """POST с чужим manager_id в теле от лица manager — ИГНОРИРУЕТСЯ, сотрудник создаётся под ЕГО собственным manager_id, не под указанным чужим.""" mgr_a_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") mgr_b_id = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") assert mgr_a_id != mgr_b_id _login(client, "mgr_a", "Secret123!") resp = client.post( "/api/v1/team/employees", json={ "username": "emp_spoof", "password": "Secret123!", "manager_id": mgr_b_id, }, ) assert resp.status_code == 201, resp.text assert resp.json()["manager_id"] == mgr_a_id assert store.users["emp_spoof"]["manager_id"] == mgr_a_id # --------------------------------------------------------------------------- # PATCH /employees/{id} — block/unblock/quota/profile/password # --------------------------------------------------------------------------- def test_block_employee_revokes_sessions(client: TestClient, store: _Store) -> None: mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") emp_id = store.add_user( "emp_a", hash_password("EmpSecret1!"), role="employee", manager_id=mgr_id ) # Employee logs in first — real session created via the real auth flow. emp_client = TestClient(client.app, base_url="https://testserver") _login(emp_client, "emp_a", "EmpSecret1!") assert any(sess["user_id"] == emp_id for sess in store.sessions.values()) _login(client, "mgr_a", "Secret123!") resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"is_active": False}) assert resp.status_code == 200, resp.text assert resp.json()["is_active"] is False # Blocking must have revoked ALL of emp_a's sessions. assert not any(sess["user_id"] == emp_id for sess in store.sessions.values()) assert any(e["event_type"] == "employee_blocked" for e in _EVENTS) def test_reset_password_revokes_old_sessions(client: TestClient, store: _Store) -> None: """HIGH (deep-review PR #2563): смена пароля обязана ревокать ВСЕ существующие сессии сотрудника — иначе скомпрометированная/чужая сессия переживает reset (sliding-refresh в auth_session.py продлевает её бесконечно, а не «до TTL»).""" mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") emp_id = store.add_user( "emp_a", hash_password("OldSecret1!"), role="employee", manager_id=mgr_id ) emp_client = TestClient(client.app, base_url="https://testserver") _login(emp_client, "emp_a", "OldSecret1!") assert any(sess["user_id"] == emp_id for sess in store.sessions.values()) _login(client, "mgr_a", "Secret123!") resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"new_password": "NewSecret2!"}) assert resp.status_code == 200, resp.text # Password reset must have revoked ALL of emp_a's pre-existing sessions — # not just when is_active is explicitly set to False. assert not any(sess["user_id"] == emp_id for sess in store.sessions.values()) def test_unblock_employee_event(client: TestClient, store: _Store) -> None: mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") emp_id = store.add_user( "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id, access_state=AccessState.DISABLED, ) _login(client, "mgr_a", "Secret123!") resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"is_active": True}) assert resp.status_code == 200 assert resp.json()["is_active"] is True assert any(e["event_type"] == "employee_unblocked" for e in _EVENTS) def test_patch_monthly_limit_reflected_in_quota_status(client: TestClient, store: _Store) -> None: mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") emp_id = store.add_user( "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id ) _login(client, "mgr_a", "Secret123!") resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"monthly_limit": 30}) assert resp.status_code == 200, resp.text assert resp.json()["quota"]["limit"] == 30 assert any(e["event_type"] == "quota_changed" for e in _EVENTS) listing = client.get("/api/v1/team/employees") assert listing.status_code == 200 entry = next(e for e in listing.json() if e["id"] == emp_id) assert entry["quota"]["limit"] == 30 def test_patch_reset_password_no_password_in_events(client: TestClient, store: _Store) -> None: mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") emp_id = store.add_user( "emp_a", hash_password("OldSecret1!"), role="employee", manager_id=mgr_id ) _login(client, "mgr_a", "Secret123!") resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"new_password": "NewSecret2!"}) assert resp.status_code == 200, resp.text assert any(e["event_type"] == "employee_password_reset" for e in _EVENTS) assert "NewSecret2!" not in str(_EVENTS) emp_client = TestClient(client.app, base_url="https://testserver") login_resp = emp_client.post( "/api/v1/auth/login", json={"username": "emp_a", "password": "NewSecret2!"} ) assert login_resp.status_code == 200 def test_patch_no_session_401(client: TestClient, store: _Store) -> None: mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") emp_id = store.add_user( "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id ) resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"is_active": False}) assert resp.status_code == 401 # --------------------------------------------------------------------------- # GET /employees — list + admin filter # --------------------------------------------------------------------------- def test_admin_sees_all_employees_and_filters_by_manager(client: TestClient, store: _Store) -> None: store.add_user("admin1", hash_password("Secret123!"), role="admin") mgr_a = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") mgr_b = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") store.add_user("emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_a) store.add_user("emp_b", hash_password("Secret123!"), role="employee", manager_id=mgr_b) _login(client, "admin1", "Secret123!") resp_all = client.get("/api/v1/team/employees") assert resp_all.status_code == 200 # Без фильтра admin видит и менеджеров — иначе им нечем сбросить пароль # (инцидент 2026-07-31). Сам admin в выдачу не попадает. assert {e["username"] for e in resp_all.json()} == {"emp_a", "emp_b", "mgr_a", "mgr_b"} # ?manager_id= — по-прежнему ТОЛЬКО сотрудники этого менеджера. resp_filtered = client.get("/api/v1/team/employees", params={"manager_id": mgr_a}) assert resp_filtered.status_code == 200 assert {e["username"] for e in resp_filtered.json()} == {"emp_a"} def test_list_employees_query_count_is_not_n_plus_1(client: TestClient, store: _Store) -> None: """Medium2 (deep-review PR #2563): pre-fix measured 2N+3 = 23 SQL queries for N=10 employees (per-employee `account_quota.get_status`). Batch quota lookup (`_batch_quota_status`) must keep the query count constant regardless of N.""" store.add_user("admin1", hash_password("Secret123!"), role="admin") mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") for i in range(10): store.add_user(f"emp_{i}", hash_password("Secret123!"), role="employee", manager_id=mgr_id) _login(client, "admin1", "Secret123!") store.query_count = 0 # reset after login's own DB traffic resp = client.get("/api/v1/team/employees") assert resp.status_code == 200 assert len(resp.json()) == 11 # 10 сотрудников + mgr_a (admin видит менеджеров) # rbac_guard session lookup (1) + current_team_actor session lookup (1) + # list query (1) + 2 batch quota queries = 5, flat regardless of N. assert store.query_count <= 5, f"expected O(1) queries for N=10, got {store.query_count}" def test_list_employees_pagination(client: TestClient, store: _Store) -> None: store.add_user("admin1", hash_password("Secret123!"), role="admin") mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") for i in range(5): store.add_user(f"emp_{i}", hash_password("Secret123!"), role="employee", manager_id=mgr_id) _login(client, "admin1", "Secret123!") resp = client.get("/api/v1/team/employees", params={"limit": 2, "offset": 1}) assert resp.status_code == 200 assert len(resp.json()) == 2 def test_list_employees_limit_max_200(client: TestClient, store: _Store) -> None: store.add_user("admin1", hash_password("Secret123!"), role="admin") _login(client, "admin1", "Secret123!") resp = client.get("/api/v1/team/employees", params={"limit": 500}) assert resp.status_code == 422 def test_list_employees_pagination_stable_with_identical_created_at( client: TestClient, store: _Store ) -> None: """Follow-up review PR #2563 п.1: `created_at DEFAULT now()` — время ТРАНЗАКЦИИ, bulk-seed (#2557) вставляет много юзеров одной транзакцией → идентичный timestamp у N+ строк. Без `id DESC` тай-брейкера порядок между страницами на PostgreSQL для строк-«близнецов» не гарантирован — сотрудники пропадали/ дублировались бы при постраничном листании. Вставляем 5 сотрудников с ОДИНАКОВЫМ created_at, листаем limit=2 постранично — объединение страниц обязано дать полный набор без дублей и пропусков.""" store.add_user("admin1", hash_password("Secret123!"), role="admin") mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") same_ts = datetime.now(UTC) # Менеджер тоже в выдаче admin'а (см. test_admin_list_includes_managers) — # он такая же строка для пейджинга, тай-брейкер обязан покрывать и её. expected_usernames = {"mgr_a"} for i in range(5): username = f"emp_tie_{i}" store.add_user( username, hash_password("Secret123!"), role="employee", manager_id=mgr_id, created_at=same_ts, ) expected_usernames.add(username) _login(client, "admin1", "Secret123!") seen: list[str] = [] offset = 0 while True: resp = client.get("/api/v1/team/employees", params={"limit": 2, "offset": offset}) assert resp.status_code == 200, resp.text page = [e["username"] for e in resp.json()] if not page: break seen.extend(page) offset += 2 assert len(seen) == len(expected_usernames), ( f"page union has {len(seen)} entries (dupes or gaps), expected " f"{len(expected_usernames)}: {seen}" ) assert set(seen) == expected_usernames # --------------------------------------------------------------------------- # CSRF defense-in-depth — Origin/Referer check on state-changing team routes # --------------------------------------------------------------------------- def test_create_employee_origin_mismatch_403(client: TestClient, store: _Store) -> None: store.add_user("mgr_a", hash_password("Secret123!"), role="manager") _login(client, "mgr_a", "Secret123!") resp = client.post( "/api/v1/team/employees", json={"username": "emp_evil", "password": "Secret123!"}, headers={"Origin": "https://evil.example"}, ) assert resp.status_code == 403 assert "emp_evil" not in store.users def test_create_employee_origin_allowed(client: TestClient, store: _Store) -> None: store.add_user("mgr_a", hash_password("Secret123!"), role="manager") _login(client, "mgr_a", "Secret123!") allowed_origin = config.settings.cors_origins[0] resp = client.post( "/api/v1/team/employees", json={"username": "emp_ok", "password": "Secret123!"}, headers={"Origin": allowed_origin}, ) assert resp.status_code == 201, resp.text def test_create_employee_no_origin_no_referer_allowed(client: TestClient, store: _Store) -> None: """curl-смоук внутри контейнера не шлёт ни Origin, ни Referer — не должен ломаться.""" store.add_user("mgr_a", hash_password("Secret123!"), role="manager") _login(client, "mgr_a", "Secret123!") resp = client.post( "/api/v1/team/employees", json={"username": "emp_curl", "password": "Secret123!"}, ) assert resp.status_code == 201, resp.text def test_patch_employee_origin_mismatch_403(client: TestClient, store: _Store) -> None: mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") emp_id = store.add_user( "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id ) _login(client, "mgr_a", "Secret123!") resp = client.patch( f"/api/v1/team/employees/{emp_id}", json={"display_name": "hacked"}, headers={"Referer": "https://evil.example/csrf.html"}, ) assert resp.status_code == 403 assert store.users["emp_a"]["display_name"] != "hacked" # --------------------------------------------------------------------------- # _batch_quota_status unlimited semantics — must match account_quota.is_unlimited # --------------------------------------------------------------------------- def test_batch_quota_unlimited_ignored_for_non_roles_yaml_username( client: TestClient, store: _Store ) -> None: """Follow-up review PR #2563 п.2: `account_quota.is_unlimited` short-circuits to False for a username NOT in roles.yaml — it never even reads `account_quota_overrides.unlimited`. The batch quota status used by GET /employees must agree, or the list would show "unlimited" for a quota that real enforcement (check_and_raise/increment, same is_unlimited) does NOT honor — a misleading display. `emp_ghost_unlimited` is a fresh DB-only username guaranteed absent from roles.yaml.""" store.add_user("admin1", hash_password("Secret123!"), role="admin") mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") store.add_user( "emp_ghost_unlimited", hash_password("Secret123!"), role="employee", manager_id=mgr_id ) store.quota_overrides["emp_ghost_unlimited"] = { "monthly_limit": 15, "unlimited": True, "note": "manual grant via SQL runbook (not through team-api)", } _login(client, "admin1", "Secret123!") resp = client.get("/api/v1/team/employees") assert resp.status_code == 200, resp.text entry = next(e for e in resp.json() if e["username"] == "emp_ghost_unlimited") # DB override says unlimited=true, but username is NOT in roles.yaml — real # enforcement would never see it, so the list must NOT claim "unlimited". assert entry["quota"]["unlimited"] is False assert entry["quota"]["limit"] == 15 def test_batch_quota_unlimited_honored_for_roles_yaml_username( client: TestClient, store: _Store ) -> None: """Symmetric positive case: a username actually present in roles.yaml (non-admin role) — `account_quota_overrides.unlimited=true` IS honored, same as `account_quota.is_unlimited`. Uses `kopylov` — real prod pilot-role entry in auth/roles.yaml (see app/core/auth.py module docstring).""" store.add_user("admin1", hash_password("Secret123!"), role="admin") mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") store.add_user("kopylov", hash_password("Secret123!"), role="employee", manager_id=mgr_id) store.quota_overrides["kopylov"] = { "monthly_limit": 999, "unlimited": True, "note": "existing prod grant", } _login(client, "admin1", "Secret123!") resp = client.get("/api/v1/team/employees") assert resp.status_code == 200, resp.text entry = next(e for e in resp.json() if e["username"] == "kopylov") assert entry["quota"]["unlimited"] is True # --------------------------------------------------------------------------- # GET /employees/{id}/history # --------------------------------------------------------------------------- def test_employee_history_happy_path(client: TestClient, store: _Store) -> None: mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") emp_id = store.add_user( "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id ) now = datetime.now(UTC) store.add_estimate_event( "emp_a", address="ул. Малышева, 10", median_price=6_500_000, confidence="high", n_analogs=12, created_at=now - timedelta(minutes=5), ) store.add_estimate_event("emp_a", address="ул. Мамина-Сибиряка, 5", created_at=now) _login(client, "mgr_a", "Secret123!") resp = client.get(f"/api/v1/team/employees/{emp_id}/history") assert resp.status_code == 200, resp.text body = resp.json() assert len(body) == 2 assert body[0]["address"] == "ул. Мамина-Сибиряка, 5" # most recent first priced = next(e for e in body if e["address"] == "ул. Малышева, 10") assert priced["median_price"] == 6_500_000 assert priced["confidence"] == "high" def test_employee_history_pagination(client: TestClient, store: _Store) -> None: mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") emp_id = store.add_user( "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id ) for i in range(5): store.add_estimate_event("emp_a", address=f"адрес-{i}") _login(client, "mgr_a", "Secret123!") resp = client.get(f"/api/v1/team/employees/{emp_id}/history", params={"limit": 2, "offset": 1}) assert resp.status_code == 200 assert len(resp.json()) == 2 def test_employee_history_limit_max_200(client: TestClient, store: _Store) -> None: mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") emp_id = store.add_user( "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id ) _login(client, "mgr_a", "Secret123!") resp = client.get(f"/api/v1/team/employees/{emp_id}/history", params={"limit": 500}) assert resp.status_code == 422 # --------------------------------------------------------------------------- # Эпик «единый вход»: режим IDENTITY_STORE=auth (общий реестр в БД `auth`). # # Всё выше идёт в ДЕФОЛТНОМ режиме — он же прод. Ниже — то, что появляется # только после переезда: другая БД под реестром (две сессии вместо одной) и # текстовое трёхзначное состояние доступа вместо булева `is_active`. # --------------------------------------------------------------------------- def test_default_mode_single_session_and_tradein_tables(client: TestClient, store: _Store) -> None: """Дефолт: реестр и продуктовые таблицы — ОДНА сессия, один commit, старые имена. Это и есть «после мержа прод работает точно как сейчас» на уровне транзакции: «сотрудник создан, квота нет» невозможно, потому что писать обоих некуда, кроме одной транзакции. """ store.add_user("mgr_a", hash_password("Secret123!"), role="manager") _login(client, "mgr_a", "Secret123!") store.commits.clear() resp = client.post( "/api/v1/team/employees", json={"username": "emp_x", "password": "Secret123!", "monthly_limit": 7}, ) assert resp.status_code == 201, resp.text # Ровно один commit и ровно на одной сессии — `db is identity_db`. assert len(set(store.commits)) == 1, store.commits 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) # Новый сотрудник заводится открытым — булевым литералом, как и раньше. assert store.users["emp_x"]["access_state"] is True assert resp.json()["is_active"] is True def test_auth_mode_commits_registry_and_product_db_separately( auth_store: _Store, client: TestClient ) -> None: """Режим `auth`: БД физически две → две сессии и два отдельных коммита. Порядок несущий (реестр первым): не доехавшая квота — это сотрудник с глобальным лимитом (чинится повторным PATCH), а обратный порядок оставил бы висящий override на несуществующего человека. """ auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager") _login(client, "mgr_a", "Secret123!") auth_store.commits.clear() resp = client.post( "/api/v1/team/employees", json={"username": "emp_x", "password": "Secret123!", "monthly_limit": 7}, ) assert resp.status_code == 201, resp.text assert len(set(auth_store.commits)) == 2, auth_store.commits joined = "\n".join(auth_store.sql_log) assert "tradein_users" not in joined assert "tradein_sessions" not in joined assert re.search(r"INSERT INTO\s+users\b", joined) # Квота осталась в ПРОДУКТОВОЙ таблице — она в общий реестр не переезжает. assert "INSERT INTO account_quota_overrides" in joined assert auth_store.quota_overrides["emp_x"]["monthly_limit"] == 7 def test_auth_mode_create_writes_text_active_literal( auth_store: _Store, client: TestClient ) -> None: """INSERT кладёт в колонку 'active' (text), а не булев true. Значение fake хранит как есть — если бы `access_state_param()` инвертировался или отдавал не тот тип, это доехало бы прямо сюда и до `is_active` в ответе. """ auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager") _login(client, "mgr_a", "Secret123!") resp = client.post( "/api/v1/team/employees", json={"username": "emp_x", "password": "Secret123!"} ) assert resp.status_code == 201, resp.text assert auth_store.users["emp_x"]["access_state"] == "active" assert resp.json()["is_active"] is True def test_auth_mode_block_writes_disabled_and_revokes_sessions( auth_store: _Store, client: TestClient ) -> None: """PATCH is_active=false → колонка 'disabled' + все сессии сотрудника порваны. Сессии живут в БД РЕЕСТРА, поэтому рвать их надо через `identity_db`: с продуктовой сессией DELETE ушёл бы не в ту БД, и блокировка не действовала бы до истечения TTL (а sliding-refresh продлевал бы её бесконечно). """ mgr_id = auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager") emp_id = auth_store.add_user( "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id ) auth_store.sessions["emp-token"] = { "user_id": emp_id, "expires_at": datetime.now(UTC) + timedelta(hours=1), "last_seen_at": datetime.now(UTC), } _login(client, "mgr_a", "Secret123!") resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"is_active": False}) assert resp.status_code == 200, resp.text assert resp.json()["is_active"] is False assert auth_store.users["emp_a"]["access_state"] == "disabled" assert "emp-token" not in auth_store.sessions def test_auth_mode_trial_expired_shows_as_blocked_and_unblock_activates( auth_store: _Store, client: TestClient ) -> None: """`trial_expired` в «Команде» выглядит заблокированным, а is_active=true снимает пробное ограничение (переводит в `active`). Форма ответа API не меняется этим PR: `is_active` остаётся булевым и считается как «пустят ли входить». Отдельное отображение пробного периода — вопрос UI-PR'а. """ mgr_id = auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager") emp_id = auth_store.add_user( "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id, access_state=AccessState.TRIAL_EXPIRED, ) _login(client, "mgr_a", "Secret123!") listed = client.get("/api/v1/team/employees") assert listed.status_code == 200, listed.text assert [e["is_active"] for e in listed.json()] == [False] resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"is_active": True}) assert resp.status_code == 200, resp.text assert resp.json()["is_active"] is True assert auth_store.users["emp_a"]["access_state"] == "active" def test_auth_mode_org_isolation_still_404s_foreign_employee( auth_store: _Store, client: TestClient ) -> None: """Главный инвариант «Команды» (чужой сотрудник → 404, не 403) переезд переживает.""" auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager") mgr_b_id = auth_store.add_user("mgr_b", hash_password("Secret123!"), role="manager") foreign_id = auth_store.add_user( "emp_b", hash_password("Secret123!"), role="employee", manager_id=mgr_b_id ) _login(client, "mgr_a", "Secret123!") assert client.get("/api/v1/team/employees").json() == [] patched = client.patch(f"/api/v1/team/employees/{foreign_id}", json={"is_active": False}) assert patched.status_code == 404 assert client.get(f"/api/v1/team/employees/{foreign_id}/history").status_code == 404 # Чужая строка не тронута. assert auth_store.users["emp_b"]["access_state"] == "active" def test_auth_mode_employee_role_still_403_on_team_routes( auth_store: _Store, client: TestClient ) -> None: """Роль резолвится из общего реестра — employee по-прежнему не админ «Команды».""" auth_store.add_user("emp_only", hash_password("Secret123!"), role="employee") _login(client, "emp_only", "Secret123!") resp = client.get("/api/v1/team/employees") assert resp.status_code == 403 assert "admin or manager" in resp.json()["detail"].lower()