gendesign/tradein-mvp/backend/tests/test_team_api.py
bot-backend eccb895db1
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
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 / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 2m40s
feat(tradein): переключаемый реестр людей — подготовка переезда «Меры» в БД auth [PR-2b/6]
Дефолт не меняет ничего: IDENTITY_STORE="tradein" — это сегодняшний прод,
tradein_users/tradein_sessions, соединение с БД auth не открывается вообще.
Переключение делается одной переменной окружения ПОСЛЕ того, как на проде
появится пароль auth_app и будут скопированы данные. Так сделано намеренно:
мерж, который зависит от невыполненного ручного шага, — это мерж, который
ломает прод в момент невнимательности.

Ядро. app/services/identity_store.py — единственное место, знающее, в какой БД
и в каких таблицах живёт реестр. Имена таблиц берутся из фиксированного словаря
по значению флага, не конкатенацией с вводом. app/core/auth_db.py — ЛЕНИВЫЙ
engine БД auth (core/db.py создаёт свой на импорте; такое же для auth роняло бы
старт без DSN).

Одно понятие состояния доступа вместо двух. В tradein_users состояние — булев
is_active, в auth.users — access_state из трёх значений. Конверсия живёт в одной
функции to_access_state(): True→active, False→disabled, а неизвестная строка,
NULL или чужой тип → disabled с WARNING. Fail-closed выбран сознательно: если
следующая миграция добавит четвёртое состояние, оно по умолчанию НЕ будет
пускать. Проверка доступа — свойство can_sign_in, а не сравнение со строкой.

Логин в режиме auth. Пароль проверяется ВСЕГДА и ДО ветвления по состоянию —
иначе появляется timing-oracle и перечисление логинов. Верный пароль +
trial_expired → 403 с машиночитаемым code="access_expired", сессия НЕ создаётся.
Верный пароль + disabled → тот же generic 401, что и при неверном пароле.
Резолв уже выданной сессии пропускает только active — блокировка обрывает
сессию немедленно, а не по истечении sliding-refresh.

Старт падает явно, если IDENTITY_STORE=auth, а DSN не задан. Без этого ошибка
конфигурации не похожа на аварию: продуктовая БД жива, приложение работает, а
rbac_guard ловит исключение резолва вместе с любым другим сбоем и падает в
legacy trusted-header ветку — то есть сутками раздаёт права из roles.yaml мимо
реестра, включая аккаунты с disabled.

Форма входа понимает новый код ответа. Ветвление по detail.code, а не по тексту:
текст бэк вправе менять, код — нет.

Гранты соблюдены, а не обойдены: auth_app не имеет UPDATE на role/manager_id и
не имеет DELETE на users (миграция 004, column-level).

Тесты: 2996 passed (+59). Единственный красный — test_search_cache_hit —
предсуществующий: проверен контрольным полным прогоном на чистом main
(2937 passed, тот же красный).
2026-08-01 02:50:14 +03:00

1416 lines
63 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 #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()