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
Дефолт не меняет ничего: 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, тот же красный).
1416 lines
63 KiB
Python
1416 lines
63 KiB
Python
"""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()
|