From 712c56f4565bcc1deb4dadefae1ae8f767f70ca6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 20:51:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein/team):=20team-management=20API=20?= =?UTF-8?q?=E2=80=94=20employees=20CRUD,=20quotas,=20stats=20(#2554)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session-only identity (current_team_actor, admin|manager) поверх tradein_users/ tradein_sessions (#2552 foundation). Org-изоляция manager <-> employee через manager_id: чужой/несуществующий employee_id -> 404 (не 403 — не палим существование), POST с чужим manager_id в теле от manager игнорируется (принудительно свой id). Квота — upsert в account_quota_overrides (существующий паттерн, без правки account_quota.py). История оценок — user_events LEFT JOIN trade_in_estimates. Team-события (employee_created/blocked/unblocked/ password_reset/quota_changed) без пароля в payload. --- tradein-mvp/backend/app/api/v1/team.py | 471 +++++++++++++ tradein-mvp/backend/app/main.py | 2 + tradein-mvp/backend/app/schemas/team.py | 103 +++ tradein-mvp/backend/tests/test_team_api.py | 757 +++++++++++++++++++++ 4 files changed, 1333 insertions(+) create mode 100644 tradein-mvp/backend/app/api/v1/team.py create mode 100644 tradein-mvp/backend/app/schemas/team.py create mode 100644 tradein-mvp/backend/tests/test_team_api.py diff --git a/tradein-mvp/backend/app/api/v1/team.py b/tradein-mvp/backend/app/api/v1/team.py new file mode 100644 index 00000000..8da7a6e1 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/team.py @@ -0,0 +1,471 @@ +"""Team-management API — CRUD сотрудников, квоты, история (#2554, эпик #2549). + +Mounted at `/api/v1/team`; через Caddy `uri strip_prefix /trade-in` это +`/trade-in/api/v1/team/*` снаружи. `app.services.auth_session.DB_ROLE_PATHS` +уже закладывает `/api/v1/team/**` в scope роли `manager` (и `admin` через `/**`) +для `rbac_guard` (см. `app.core.rbac`) — этот роутер добавляет ВТОРОЙ, +более узкий барьер именно на identity: + + - `current_team_actor` резолвит юзера ТОЛЬКО из session-cookie + (`app.services.auth_session.get_session_user`). Legacy + `X-Authenticated-User` (Caddy trusted-header, dual-mode) НЕ принимается + здесь — team-API новый, не участвует в переходном dual-mode auth. Без + валидной cookie — 401, даже если `rbac_guard` пропустил запрос по + legacy-заголовку (напр. admin через roles.yaml). + - Роль должна быть `admin` или `manager` — иначе 403. + +Org-изоляция (главный инвариант фичи): manager видит/меняет ТОЛЬКО своих +employee (`tradein_users.manager_id = actor.user_id`). Чужой/несуществующий +employee_id → 404 (НЕ 403) — не подтверждаем/не опровергаем существование +чужого сотрудника перед manager'ом. См. `_authorize_employee`. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy import text +from sqlalchemy.engine import RowMapping +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.db import get_db +from app.core.password import hash_password +from app.schemas.team import ( + EmployeeCreateRequest, + EmployeeHistoryEntry, + EmployeeOut, + EmployeeUpdateRequest, + QuotaStatusOut, +) +from app.services import account_quota +from app.services.auth_session import get_session_user, revoke_user_sessions +from app.services.user_events import schedule_event + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@dataclass +class TeamActor: + """Резолвленный из session-cookie актёр team-API — admin или manager.""" + + user_id: int + username: str + role: str # "admin" | "manager" + + +async def current_team_actor( + request: Request, + db: Annotated[Session, Depends(get_db)], +) -> TeamActor: + """Dependency: session-only identity, роль admin|manager, иначе 401/403. + + Намеренно НЕ читает `X-Authenticated-User` — см. модульный docstring. + """ + token = request.cookies.get(settings.session_cookie_name) + if not token: + raise HTTPException(status_code=401, detail="valid session required") + + try: + session_user = get_session_user(db, token) + except Exception: + logger.exception("team: session lookup failed") + raise HTTPException(status_code=401, detail="valid session required") from None + + if session_user is None: + raise HTTPException(status_code=401, detail="valid session required") + + role = session_user["role"] + if role not in ("admin", "manager"): + raise HTTPException(status_code=403, detail="admin or manager role required") + + return TeamActor( + user_id=session_user["user_id"], + username=session_user["username"], + role=role, + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _fetch_employee_row(db: Session, employee_id: int) -> RowMapping | None: + return ( + db.execute( + text( + """ + SELECT id, username, display_name, org_name, email, is_active, + manager_id, created_at + FROM tradein_users + WHERE id = :id AND role = 'employee' + """ + ), + {"id": employee_id}, + ) + .mappings() + .fetchone() + ) + + +def _authorize_employee(actor: TeamActor, row: RowMapping | None) -> RowMapping: + """404 (НЕ 403) если сотрудник не найден ИЛИ принадлежит другому manager'у. + + Org-изоляция: manager может видеть/менять только `manager_id == actor.user_id`. + 404 вместо 403 — не палим существование чужого employee_id. + """ + if row is None: + raise HTTPException(status_code=404, detail="employee not found") + if actor.role == "manager" and row["manager_id"] != actor.user_id: + raise HTTPException(status_code=404, detail="employee not found") + return row + + +def _upsert_quota_override( + db: Session, username: str, monthly_limit: int, actor_username: str +) -> None: + db.execute( + text( + """ + INSERT INTO account_quota_overrides (username, monthly_limit, note) + VALUES (:username, CAST(:monthly_limit AS integer), :note) + ON CONFLICT (username) DO UPDATE SET + monthly_limit = EXCLUDED.monthly_limit, + note = EXCLUDED.note, + updated_at = now() + """ + ), + { + "username": username, + "monthly_limit": monthly_limit, + "note": f"team-api: set by {actor_username}", + }, + ) + + +def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut: + return EmployeeOut( + id=row["id"], + username=row["username"], + display_name=row["display_name"], + org_name=row["org_name"], + email=row["email"], + is_active=row["is_active"], + manager_id=row["manager_id"], + created_at=row["created_at"], + quota=QuotaStatusOut(**quota), + ) + + +# --------------------------------------------------------------------------- +# POST /employees +# --------------------------------------------------------------------------- + + +@router.post("/employees", response_model=EmployeeOut, status_code=201) +async def create_employee( + body: EmployeeCreateRequest, + actor: Annotated[TeamActor, Depends(current_team_actor)], + db: Annotated[Session, Depends(get_db)], +) -> EmployeeOut: + """Создать сотрудника. Роль всегда `employee`. + + manager_id: для actor.role == manager — принудительно свой id (любое + значение из тела ИГНОРИРУЕТСЯ, org-изоляция инвариант #2554). Для + actor.role == admin — опционально из тела, валидируется что указанный id + существует и role='manager' (иначе 422). + """ + existing = db.execute( + text("SELECT id FROM tradein_users WHERE username = :u"), + {"u": body.username}, + ).fetchone() + if existing is not None: + raise HTTPException(status_code=409, detail="username already exists") + + try: + password_hash = hash_password(body.password) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) from None + + manager_id: int | None + if actor.role == "manager": + # Инвариант org-изоляции: manager не может создать сотрудника под + # чужим manager_id — любое значение из тела игнорируется молча. + manager_id = actor.user_id + else: + manager_id = body.manager_id + if manager_id is not None: + mgr = db.execute( + text("SELECT id FROM tradein_users WHERE id = :id AND role = 'manager'"), + {"id": manager_id}, + ).fetchone() + if mgr is None: + raise HTTPException( + status_code=422, + detail="manager_id does not reference an existing manager", + ) + + try: + row = ( + db.execute( + text( + """ + INSERT INTO tradein_users + (username, password_hash, role, manager_id, display_name, org_name, + email, is_active) + VALUES + (:username, :password_hash, 'employee', :manager_id, :display_name, + :org_name, :email, true) + RETURNING id, username, display_name, org_name, email, is_active, + manager_id, created_at + """ + ), + { + "username": body.username, + "password_hash": password_hash, + "manager_id": manager_id, + "display_name": body.display_name, + "org_name": body.org_name, + "email": body.email, + }, + ) + .mappings() + .fetchone() + ) + except IntegrityError: + # TOCTOU: два конкурентных POST с одинаковым username между pre-check + # выше и этим INSERT — UNIQUE-констрейнт на tradein_users.username ловит. + db.rollback() + raise HTTPException(status_code=409, detail="username already exists") from None + + assert row is not None # RETURNING на успешный INSERT всегда отдаёт строку + + if body.monthly_limit is not None: + _upsert_quota_override(db, body.username, body.monthly_limit, actor.username) + + db.commit() + + schedule_event( + event_type="employee_created", + username=actor.username, + payload={ + "employee_id": row["id"], + "employee_username": row["username"], + "manager_id": manager_id, + }, + ) + + quota = account_quota.get_status(db, body.username) + return _employee_out(row, quota) + + +# --------------------------------------------------------------------------- +# PATCH /employees/{id} +# --------------------------------------------------------------------------- + + +@router.patch("/employees/{employee_id}", response_model=EmployeeOut) +async def update_employee( + employee_id: int, + body: EmployeeUpdateRequest, + actor: Annotated[TeamActor, Depends(current_team_actor)], + db: Annotated[Session, Depends(get_db)], +) -> EmployeeOut: + """Частичное обновление сотрудника — block/unblock, лимит, профиль, пароль. + + manager может патчить ТОЛЬКО своих (manager_id == actor.user_id), иначе 404. + При is_active=False — обязательно revoke всех сессий (иначе блокировка не + подействует до истечения TTL текущей сессии сотрудника). + """ + row = _fetch_employee_row(db, employee_id) + row = _authorize_employee(actor, row) + + new_password_hash: str | None = None + if body.new_password is not None: + try: + new_password_hash = hash_password(body.new_password) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) from None + + db.execute( + text( + """ + UPDATE tradein_users + SET display_name = COALESCE(:display_name, display_name), + org_name = COALESCE(:org_name, org_name), + email = COALESCE(:email, email), + is_active = COALESCE(CAST(:is_active AS boolean), is_active), + password_hash = COALESCE(:password_hash, password_hash), + updated_at = now() + WHERE id = :id + """ + ), + { + "display_name": body.display_name, + "org_name": body.org_name, + "email": body.email, + "is_active": body.is_active, + "password_hash": new_password_hash, + "id": employee_id, + }, + ) + + if body.monthly_limit is not None: + _upsert_quota_override(db, row["username"], body.monthly_limit, actor.username) + + if body.is_active is False: + # Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions + # коммитит сам (см. app.services.auth_session), это флашит и наш + # предшествующий UPDATE/quota-upsert в той же сессии. + revoke_user_sessions(db, employee_id) + + db.commit() + + changed_profile_fields = [ + f + for f, v in ( + ("display_name", body.display_name), + ("org_name", body.org_name), + ("email", body.email), + ) + if v is not None + ] + if changed_profile_fields: + schedule_event( + event_type="employee_updated", + username=actor.username, + payload={ + "employee_id": employee_id, + "employee_username": row["username"], + "fields": changed_profile_fields, + }, + ) + if body.new_password is not None: + schedule_event( + event_type="employee_password_reset", + username=actor.username, + payload={"employee_id": employee_id, "employee_username": row["username"]}, + ) + if body.is_active is not None: + schedule_event( + event_type="employee_blocked" if body.is_active is False else "employee_unblocked", + username=actor.username, + payload={"employee_id": employee_id, "employee_username": row["username"]}, + ) + if body.monthly_limit is not None: + schedule_event( + event_type="quota_changed", + username=actor.username, + payload={ + "employee_id": employee_id, + "employee_username": row["username"], + "monthly_limit": body.monthly_limit, + }, + ) + + updated_row = _fetch_employee_row(db, employee_id) + assert updated_row is not None # только что успешно обновили эту же строку + quota = account_quota.get_status(db, updated_row["username"]) + return _employee_out(updated_row, quota) + + +# --------------------------------------------------------------------------- +# GET /employees +# --------------------------------------------------------------------------- + + +@router.get("/employees", response_model=list[EmployeeOut]) +async def list_employees( + actor: Annotated[TeamActor, Depends(current_team_actor)], + db: Annotated[Session, Depends(get_db)], + manager_id: Annotated[int | None, Query()] = None, +) -> list[EmployeeOut]: + """Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=.""" + params: dict[str, Any] = {} + where = "WHERE role = 'employee'" + if actor.role == "manager": + where += " AND manager_id = :manager_id" + params["manager_id"] = actor.user_id + elif manager_id is not None: + where += " AND manager_id = :manager_id" + params["manager_id"] = manager_id + + rows = ( + db.execute( + text( + f""" + SELECT id, username, display_name, org_name, email, is_active, + manager_id, created_at + FROM tradein_users + {where} + ORDER BY created_at DESC + """ + ), + params, + ) + .mappings() + .all() + ) + + result: list[EmployeeOut] = [] + for row in rows: + quota = account_quota.get_status(db, row["username"]) + result.append(_employee_out(row, quota)) + return result + + +# --------------------------------------------------------------------------- +# GET /employees/{id}/history +# --------------------------------------------------------------------------- + + +@router.get("/employees/{employee_id}/history", response_model=list[EmployeeHistoryEntry]) +async def employee_history( + employee_id: int, + actor: Annotated[TeamActor, Depends(current_team_actor)], + db: Annotated[Session, Depends(get_db)], + limit: Annotated[int, Query(ge=1, le=200)] = 50, + offset: Annotated[int, Query(ge=0)] = 0, +) -> list[EmployeeHistoryEntry]: + """История оценок сотрудника (адрес/дата/результат) — из `user_events`, + LEFT JOIN `trade_in_estimates` за фактическим результатом. + + Та же org-проверка что и в PATCH: чужой employee_id → 404. + """ + row = _fetch_employee_row(db, employee_id) + row = _authorize_employee(actor, row) + + rows = ( + db.execute( + text( + """ + SELECT + CAST(ue.estimate_id AS text) AS estimate_id, + ue.payload ->> 'address' AS address, + ue.payload ->> 'area_m2' AS area_m2, + ue.payload ->> 'rooms' AS rooms, + te.median_price, + te.confidence, + te.n_analogs, + ue.created_at + FROM user_events ue + LEFT JOIN trade_in_estimates te ON te.id = ue.estimate_id + WHERE ue.username = :username AND ue.event_type = 'estimate_request' + ORDER BY ue.created_at DESC + LIMIT :limit OFFSET :offset + """ + ), + {"username": row["username"], "limit": limit, "offset": offset}, + ) + .mappings() + .all() + ) + + return [EmployeeHistoryEntry.model_validate(dict(r)) for r in rows] diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 35ca453e..0a45d24c 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -31,6 +31,7 @@ from app.api.v1 import ( me, search, support, + team, trade_in, ) from app.core.config import settings @@ -183,3 +184,4 @@ app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-su app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"]) app.include_router(search.router, prefix="/api/v1", tags=["search"]) app.include_router(me.router, prefix="/api/v1", tags=["me"]) +app.include_router(team.router, prefix="/api/v1/team", tags=["team"]) diff --git a/tradein-mvp/backend/app/schemas/team.py b/tradein-mvp/backend/app/schemas/team.py new file mode 100644 index 00000000..a1022247 --- /dev/null +++ b/tradein-mvp/backend/app/schemas/team.py @@ -0,0 +1,103 @@ +"""Pydantic-схемы team-management API (#2554, эпик #2549). + +CRUD сотрудников (`tradein_users.role = 'employee'`), квоты, история оценок. +Org-изоляция (manager видит/меняет только своих employee) реализована в +`app.api.v1.team`, эти схемы — только форма запросов/ответов. +""" + +from __future__ import annotations + +import re +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +# ASCII-only — не-ASCII username ломает downstream identity-пропагацию +# (`app.core.rbac._propagate_authenticated_user` кодирует latin-1 с +# errors="replace"), поэтому валидация формы обязательна на границе API, +# а не только на уровне БД. +_USERNAME_RE = re.compile(r"^[A-Za-z0-9._-]{3,64}$") + + +class QuotaStatusOut(BaseModel): + """Статус месячной квоты оценок — вложен в `EmployeeOut`.""" + + model_config = ConfigDict(from_attributes=True) + + limit: int + used: int + remaining: int + unlimited: bool + + +class EmployeeCreateRequest(BaseModel): + """`POST /employees` — создать сотрудника. Роль всегда `employee` (не в теле).""" + + username: str + password: str + display_name: str | None = None + org_name: str | None = None + email: str | None = None + monthly_limit: int | None = Field(default=None, ge=1) + # Только для actor.role == admin — опциональная привязка к конкретному manager. + # Для actor.role == manager это поле ИГНОРИРУЕТСЯ (принудительно свой id) — + # см. app.api.v1.team.create_employee. + manager_id: int | None = None + + @field_validator("username") + @classmethod + def _validate_username(cls, v: str) -> str: + if not _USERNAME_RE.match(v): + raise ValueError( + "username must be 3-64 ASCII chars: letters, digits, dot, underscore, hyphen" + ) + return v + + +class EmployeeUpdateRequest(BaseModel): + """`PATCH /employees/{id}` — частичное обновление, все поля опциональны.""" + + is_active: bool | None = None + monthly_limit: int | None = Field(default=None, ge=1) + display_name: str | None = None + org_name: str | None = None + email: str | None = None + new_password: str | None = None + + +class EmployeeOut(BaseModel): + """Одна строка в `GET /employees` + ответ `POST`/`PATCH /employees/{id}`.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + username: str + display_name: str | None = None + org_name: str | None = None + email: str | None = None + is_active: bool + manager_id: int | None = None + created_at: datetime + quota: QuotaStatusOut + + +class EmployeeHistoryEntry(BaseModel): + """Одна строка истории оценок сотрудника — `GET /employees/{id}/history`. + + Источник — `user_events` (event_type='estimate_request', паттерн + `app.api.v1.audit.account_drilldown`), LEFT JOIN на `trade_in_estimates` + за фактическим результатом (median_price/confidence/n_analogs) — join + может не сматчиться (старая запись без estimate_id / оценка insufficient_data), + поэтому все result-поля nullable. + """ + + model_config = ConfigDict(from_attributes=True) + + estimate_id: str | None = None + address: str | None = None + area_m2: str | None = None + rooms: str | None = None + median_price: int | None = None + confidence: str | None = None + n_analogs: int | None = None + created_at: datetime diff --git a/tradein-mvp/backend/tests/test_team_api.py b/tradein-mvp/backend/tests/test_team_api.py new file mode 100644 index 00000000..ae72124d --- /dev/null +++ b/tradein-mvp/backend/tests/test_team_api.py @@ -0,0 +1,757 @@ +"""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 `tradein_users` / +`tradein_sessions` / `account_quota_overrides` / `account_estimate_usage` / +`user_events` / `trade_in_estimates`. + +`app.core.rbac.SessionLocal` (middleware, no FastAPI DI) and `app.core.db.get_db` +(auth.router / team.router `Depends(get_db)`) both point at the SAME `_Store` +instance per test — a session created via POST /login is immediately visible to +rbac_guard's own DB round trip AND to `current_team_actor`. +""" + +from __future__ import annotations + +import os +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 + +# --------------------------------------------------------------------------- +# Fake DB backing tradein_users / tradein_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._next_id = 1 + + def add_user( + self, + username: str, + password_hash: str | None, + *, + role: str = "employee", + manager_id: int | None = None, + is_active: bool = True, + display_name: str | None = None, + org_name: str | None = None, + email: str | 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, + "is_active": is_active, + "created_at": 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: + pass + + 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 + + # ---- tradein_sessions ---- + if "INSERT INTO tradein_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 "UPDATE tradein_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 "DELETE FROM tradein_sessions WHERE token" in sql: + s.sessions.pop(p["token"], None) + return _Result([]) + + if "DELETE FROM tradein_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 "FROM tradein_sessions s" in sql and "JOIN tradein_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([]) + 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"], + "is_active": user["is_active"], + } + ] + ) + + # ---- tradein_users: login lookup (get_user_by_username) ---- + if "password_hash, role, is_active" in sql and "FROM tradein_users" in sql: + user = s.users.get(p["username"]) + return _Result([user] if user is not None else []) + + # ---- tradein_users: create ---- + if "INSERT INTO tradein_users" in sql: + 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"], + "is_active": True, + "created_at": created_at, + } + s.users[p["username"]] = row + return _Result([dict(row)]) + + # ---- tradein_users: manager_id validation ---- + if "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 []) + + # ---- tradein_users: list employees (has explicit ORDER BY) ---- + if "role = 'employee'" in sql and "ORDER BY created_at DESC" in sql: + rows = [u for u in s.users.values() if u["role"] == "employee"] + if "manager_id" in p: + rows = [u for u in rows if u["manager_id"] == p["manager_id"]] + rows = sorted(rows, key=lambda u: u["created_at"], reverse=True) + return _Result( + [ + { + "id": u["id"], + "username": u["username"], + "display_name": u["display_name"], + "org_name": u["org_name"], + "email": u["email"], + "is_active": u["is_active"], + "manager_id": u["manager_id"], + "created_at": u["created_at"], + } + for u in rows + ] + ) + + # ---- tradein_users: fetch single employee by id ---- + if "role = 'employee'" in sql: + user = s.user_by_id(p["id"]) + if user is None or user["role"] != "employee": + return _Result([]) + return _Result( + [ + { + "id": user["id"], + "username": user["username"], + "display_name": user["display_name"], + "org_name": user["org_name"], + "email": user["email"], + "is_active": user["is_active"], + "manager_id": user["manager_id"], + "created_at": user["created_at"], + } + ] + ) + + # ---- tradein_users: uniqueness pre-check ---- + if sql.strip().startswith("SELECT id FROM tradein_users WHERE username"): + user = s.users.get(p["u"]) + return _Result([{"id": user["id"]}] if user is not None else []) + + # ---- tradein_users: update (PATCH) ---- + if "UPDATE tradein_users" in sql and "SET display_name = COALESCE" in sql: + 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"] + if p.get("is_active") is not None: + user["is_active"] = p["is_active"] + if p.get("password_hash") is not None: + user["password_hash"] = p["password_hash"] + return _Result([]) + + # ---- account_quota_overrides upsert ---- + if "INSERT INTO account_quota_overrides" in sql: + s.quota_overrides[p["username"]] = { + "monthly_limit": p["monthly_limit"], + "unlimited": False, + "note": p["note"], + } + return _Result([]) + + 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 ---- + 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") + # 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 client(store: _Store, monkeypatch: pytest.MonkeyPatch) -> TestClient: + monkeypatch.setattr("app.core.rbac.SessionLocal", 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 + + +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 + + +# --------------------------------------------------------------------------- +# 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_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, is_active=False + ) + _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 + assert {e["username"] for e in resp_all.json()} == {"emp_a", "emp_b"} + + 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"} + + +# --------------------------------------------------------------------------- +# 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