Compare commits

..

No commits in common. "main" and "feat/tradein-auth-core" have entirely different histories.

9 changed files with 8 additions and 2030 deletions

View file

@ -1,632 +0,0 @@
"""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 urllib.parse import urlparse
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.auth import get_role
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,
)
def _origin_host_allowed(candidate: str) -> bool:
"""True если scheme://netloc *candidate* совпадает с одним из `settings.cors_origins`.
`cors_origins` уже является источником правды для «какие origin'ы это наш
фронт» (см. CORSMiddleware в app/main.py, ENV CORS_ORIGINS) переиспользуем
его вместо нового хардкода."""
try:
parsed = urlparse(candidate)
except ValueError:
return False
if not parsed.scheme or not parsed.netloc:
return False
origin = f"{parsed.scheme}://{parsed.netloc}"
return origin in settings.cors_origins
def _require_same_origin(request: Request) -> None:
"""CSRF defense-in-depth (issue #2554 DoD) для state-changing team-роутов
(POST/PATCH): `Origin` (или `Referer` как fallback) обязан матчить один из
`settings.cors_origins`, иначе 403.
Оба заголовка отсутствуют ПРОПУСКАЕМ (не 403). Причина: это единственный
надёжный сигнал non-browser клиента в этом стеке curl-смоуки внутри
контейнера (см. `.claude/rules/tradein.md` "Тестировать HTTP только ВНУТРИ
контейнера", `docker exec tradein-backend curl ...`) не шлют ни один из этих
заголовков, а реальный браузер (fetch/XHR/form) ВСЕГДА прикладывает Origin
на unsafe-методах (POST/PATCH) так что "оба отсутствуют" практически
невозможно для настоящего кросс-сайтового CSRF через браузер. Session-cookie
уже стоит на `SameSite=Lax` (см. `app.api.v1.auth.login`) это первый рубеж
против CSRF, Origin-check второй.
"""
candidate = request.headers.get("origin") or request.headers.get("referer")
if candidate is None:
return
if not _origin_host_allowed(candidate):
logger.warning(
"team: Origin/Referer mismatch %r on %s — possible CSRF", candidate, request.url.path
)
raise HTTPException(status_code=403, detail="origin not allowed")
# ---------------------------------------------------------------------------
# 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:
"""Upsert персонального лимита. Явная установка monthly_limit — сигнал "хочу
numeric-квоту", поэтому ВСЕГДА сбрасывает `unlimited=false` (иначе лимит может
молча не применяться прежний unlimited-грант выигрывал бы у нового limit).
`note` НЕ затирается, если уже задан (`COALESCE`): не перезаписываем
человеко-читаемую причину прошлого гранта (напр. "пилот, грант ...") молча
сгенерированной строкой; note проставляется только при первом upsert записи.
"""
db.execute(
text(
"""
INSERT INTO account_quota_overrides (username, monthly_limit, unlimited, note)
VALUES (:username, CAST(:monthly_limit AS integer), false, :note)
ON CONFLICT (username) DO UPDATE SET
monthly_limit = EXCLUDED.monthly_limit,
unlimited = false,
note = COALESCE(account_quota_overrides.note, EXCLUDED.note),
updated_at = now()
"""
),
{
"username": username,
"monthly_limit": monthly_limit,
"note": f"team-api: set by {actor_username}",
},
)
def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str, Any]]:
"""Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса
вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563).
Семантика ИДЕНТИЧНА `account_quota.is_unlimited`/`user_limit`/`get_status`:
unlimited = admin-роль (roles.yaml, in-memory, без похода в БД) ИЛИ
`account_quota_overrides.unlimited=true`; limit = override.monthly_limit,
иначе глобальный `account_quota.MONTHLY_LIMIT`.
"""
if not usernames:
return {}
overrides = (
db.execute(
text(
"""
SELECT username, monthly_limit, unlimited
FROM account_quota_overrides
WHERE username = ANY(CAST(:usernames AS text[]))
"""
),
{"usernames": usernames},
)
.mappings()
.all()
)
override_by_username = {r["username"]: r for r in overrides}
period = account_quota.current_period()
usage_rows = (
db.execute(
text(
"""
SELECT username, used
FROM account_estimate_usage
WHERE username = ANY(CAST(:usernames AS text[])) AND period_month = :period
"""
),
{"usernames": usernames, "period": period},
)
.mappings()
.all()
)
used_by_username = {r["username"]: r["used"] for r in usage_rows}
result: dict[str, dict[str, Any]] = {}
for username in usernames:
override = override_by_username.get(username)
try:
is_admin_role = get_role(username) == "admin"
except KeyError:
is_admin_role = False
unlimited = is_admin_role or bool(override is not None and override["unlimited"])
limit = (
int(override["monthly_limit"])
if override is not None and override["monthly_limit"] is not None
else account_quota.MONTHLY_LIMIT
)
used = used_by_username.get(username, 0)
if unlimited:
result[username] = {
"limit": limit,
"used": used,
"remaining": limit,
"unlimited": True,
}
else:
remaining = max(0, limit - max(0, used))
result[username] = {
"limit": limit,
"used": used,
"remaining": remaining,
"unlimited": False,
}
return result
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)],
_origin_check: Annotated[None, Depends(_require_same_origin)],
) -> 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)],
_origin_check: Annotated[None, Depends(_require_same_origin)],
) -> EmployeeOut:
"""Частичное обновление сотрудника — block/unblock, лимит, профиль, пароль.
manager может патчить ТОЛЬКО своих (manager_id == actor.user_id), иначе 404.
При is_active=False ИЛИ смене пароля (new_password) обязательно revoke всех
сессий (HIGH, deep-review PR #2563): без этого блокировка/reset не подействуют
до истечения TTL текущей сессии сотрудника хуже того, sliding-refresh
(`app.services.auth_session.get_session_user`) продлевает `expires_at` на
КАЖДОМ запросе, так что скомпрометированная/чужая сессия живёт неограниченно
долго, а не «до TTL». `revoke_user_sessions` сам называет смену пароля своим
use-case см. его докстринг.
"""
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 or body.new_password is not None:
# Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions
# коммитит сам (см. app.services.auth_session), это флашит и наш
# предшествующий UPDATE/quota-upsert в той же сессии. Self-lockout
# невозможен: _fetch_employee_row фильтрует role='employee', actor
# (admin|manager) никогда не может патчить сам себя через этот роут.
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
# ---------------------------------------------------------------------------
# Два статических варианта WHERE (НЕ f-string/динамическая сборка — Medium/
# "заодно" review PR #2563: значения биндятся параметрами и без того безопасны,
# но статические ветки не провоцируют будущие правки в сторону конкатенации SQL).
_LIST_EMPLOYEES_BY_MANAGER_SQL = text(
"""
SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at
FROM tradein_users
WHERE role = 'employee' AND manager_id = :manager_id
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset
"""
)
_LIST_EMPLOYEES_ALL_SQL = text(
"""
SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at
FROM tradein_users
WHERE role = 'employee'
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset
"""
)
@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,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> list[EmployeeOut]:
"""Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=.
Квота ОДИН батч-запрос на всю страницу (`_batch_quota_status`), не N+1
(Medium2, review PR #2563: было 2N+3 SQL-запросов на N сотрудников).
"""
if actor.role == "manager":
rows = (
db.execute(
_LIST_EMPLOYEES_BY_MANAGER_SQL,
{"manager_id": actor.user_id, "limit": limit, "offset": offset},
)
.mappings()
.all()
)
elif manager_id is not None:
rows = (
db.execute(
_LIST_EMPLOYEES_BY_MANAGER_SQL,
{"manager_id": manager_id, "limit": limit, "offset": offset},
)
.mappings()
.all()
)
else:
rows = (
db.execute(_LIST_EMPLOYEES_ALL_SQL, {"limit": limit, "offset": offset}).mappings().all()
)
quota_by_username = _batch_quota_status(db, [row["username"] for row in rows])
return [_employee_out(row, quota_by_username[row["username"]]) for row in rows]
# ---------------------------------------------------------------------------
# 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]

View file

@ -31,7 +31,6 @@ from app.api.v1 import (
me, me,
search, search,
support, support,
team,
trade_in, trade_in,
) )
from app.core.config import settings from app.core.config import settings
@ -184,4 +183,3 @@ 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(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
app.include_router(search.router, prefix="/api/v1", tags=["search"]) app.include_router(search.router, prefix="/api/v1", tags=["search"])
app.include_router(me.router, prefix="/api/v1", tags=["me"]) app.include_router(me.router, prefix="/api/v1", tags=["me"])
app.include_router(team.router, prefix="/api/v1/team", tags=["team"])

View file

@ -1,103 +0,0 @@
"""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

View file

@ -1,903 +0,0 @@
"""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
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,
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
s.query_count += 1
# ---- 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)
offset, limit = p.get("offset", 0), p.get("limit", len(rows))
rows = rows[offset : offset + limit]
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: 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")
# 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_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, 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"}
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()) == 10
# 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
# ---------------------------------------------------------------------------
# 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"
# ---------------------------------------------------------------------------
# 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

View file

@ -1,288 +0,0 @@
"use client";
/**
* #2555 (эпик #2549) login-форма для новой DB-backed session auth
* (POST /api/v1/auth/login, см. `tradein-mvp/backend/app/api/v1/auth.py`).
*
* Не гейтится RouteGuard'ом (см. `components/auth/RouteGuard.tsx`
* `isLoginPage` bypass) иначе редирект-петля: 401 от /me на /login тоже
* пытался бы увести на /login.
*
* `next=` читаем вручную из `window.location.search` (SSR-guard), а НЕ
* через `next/navigation` `useSearchParams()` тот форсит Suspense boundary
* и ломает `next build` (см. `app/v2/page.tsx: readUrlId` тот же паттерн,
* уже принятый в этом репо).
*/
import { useState } from "react";
import type { CSSProperties, FormEvent } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiFetch, HTTPError } from "@/lib/api";
import { ME_QUERY_KEY } from "@/lib/useMe";
interface LoginInput {
username: string;
password: string;
}
async function loginRequest(input: LoginInput): Promise<void> {
await apiFetch<{ ok: boolean }>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify(input),
});
}
function readNextParam(): string | null {
if (typeof window === "undefined") return null;
return new URLSearchParams(window.location.search).get("next");
}
/**
* Open-redirect guard: принимаем только внутренний путь, начинающийся
* ровно с одного "/" не "//host" (protocol-relative URL) и не "/\host"
* (браузеры местами трактуют backslash как forward slash в URL-парсинге).
*
* PR #2562 review finding 1: WHATWG URL-парсер (который `router.push`
* использует под капотом) убирает ВСЕ ASCII tab/CR/LF из строки ПЕРЕД
* парсингом так `"/\t//evil"` для наивного regex выглядит как безопасный
* путь с одним leading slash (символ в позиции 1 таб, не "/" и не "\"),
* а после навигации превращается в `"//evil"` (protocol-relative чужой
* origin). Убираем те же символы ДО валидации, чтобы regex видел ту же
* строку, что увидит парсер.
*
* PR #2562 review finding 2: `next=/login` (или `/login?...`) после успешного
* логина кидал бы юзера обратно на форму входа (RouteGuard не гейтит
* `/login`) dead-end. Фолбэк на "/" в этом случае.
*/
function sanitizeNext(next: string | null): string {
if (!next) return "/";
const cleaned = next.replace(/[\t\r\n]/g, "");
if (!/^\/(?!\/|\\)/.test(cleaned)) return "/";
if (
cleaned === "/login" ||
cleaned.startsWith("/login?") ||
cleaned.startsWith("/login#")
) {
return "/";
}
return cleaned;
}
function loginErrorMessage(error: unknown): string {
if (error instanceof HTTPError) {
if (error.status === 401) return "Неверный логин или пароль";
if (error.status === 429) {
return "Слишком много попыток. Попробуйте через несколько минут";
}
}
return "Не удалось войти. Проверьте подключение и попробуйте ещё раз";
}
const cardStyle: CSSProperties = {
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: "32px 28px",
maxWidth: 380,
width: "100%",
};
const labelStyle: CSSProperties = {
display: "block",
fontSize: 12,
fontWeight: 500,
color: "var(--fg-secondary)",
marginBottom: 6,
};
const inputStyle: CSSProperties = {
width: "100%",
boxSizing: "border-box",
height: 40,
padding: "0 12px",
border: "1px solid var(--border-card)",
borderRadius: 8,
fontSize: 14,
color: "var(--fg-primary)",
background: "var(--bg-card)",
fontFamily: "inherit",
};
export default function LoginPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const loginMutation = useMutation({
mutationFn: loginRequest,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
router.push(sanitizeNext(readNextParam()));
},
});
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (loginMutation.isPending) return;
loginMutation.mutate({ username: username.trim(), password });
}
return (
<main
style={{
minHeight: "100vh",
background: "var(--bg-app)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
fontFamily: "Inter, -apple-system, 'Segoe UI', system-ui, sans-serif",
}}
>
<style>{`
@keyframes login-spin { to { transform: rotate(360deg); } }
.login-spinner { animation: login-spin .7s linear infinite; }
.login-input:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.login-submit:hover:not(:disabled) { background: var(--accent-hover); }
`}</style>
<form onSubmit={handleSubmit} style={cardStyle} aria-label="Вход в Меру">
<h1
style={{
margin: "0 0 8px",
fontSize: 22,
fontWeight: 600,
color: "var(--fg-primary)",
lineHeight: 1.25,
}}
>
Вход
</h1>
<p
style={{
margin: "0 0 24px",
fontSize: 14,
color: "var(--fg-secondary)",
lineHeight: 1.5,
}}
>
Войдите, чтобы продолжить работу с Мерой.
</p>
<div style={{ marginBottom: 16 }}>
<label style={labelStyle} htmlFor="login-username">
Логин
</label>
<input
id="login-username"
name="username"
type="text"
autoComplete="username"
required
autoFocus
className="login-input"
style={inputStyle}
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loginMutation.isPending}
/>
</div>
<div style={{ marginBottom: 24 }}>
<label style={labelStyle} htmlFor="login-password">
Пароль
</label>
<input
id="login-password"
name="password"
type="password"
autoComplete="current-password"
required
className="login-input"
style={inputStyle}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loginMutation.isPending}
/>
</div>
{loginMutation.isError ? (
<p
role="alert"
style={{
margin: "0 0 16px",
padding: "8px 12px",
borderRadius: 8,
background: "var(--danger-soft)",
color: "var(--danger)",
fontSize: 13,
lineHeight: 1.4,
}}
>
{loginErrorMessage(loginMutation.error)}
</p>
) : null}
<button
type="submit"
className="login-submit"
disabled={loginMutation.isPending}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
background: "var(--accent)",
color: "#FFFFFF",
border: "none",
borderRadius: 8,
padding: "10px 16px",
fontSize: 14,
fontWeight: 500,
cursor: loginMutation.isPending ? "wait" : "pointer",
opacity: loginMutation.isPending ? 0.75 : 1,
fontFamily: "inherit",
}}
>
{loginMutation.isPending ? (
<>
<svg
className="login-spinner"
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r="9"
stroke="rgba(255,255,255,0.35)"
strokeWidth="3"
/>
<path
d="M21 12a9 9 0 0 0-9-9"
stroke="#FFFFFF"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
Входим
</>
) : (
"Войти"
)}
</button>
</form>
</main>
);
}

View file

@ -66,7 +66,7 @@ import {
} from "@/lib/trade-in-api"; } from "@/lib/trade-in-api";
import { useQuota } from "@/lib/useQuota"; import { useQuota } from "@/lib/useQuota";
import { useMe } from "@/lib/useMe"; import { useMe } from "@/lib/useMe";
import { useLogout } from "@/lib/useLogout"; import { logout } from "@/lib/logout";
// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative, // OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
// non-interactive overlay drawn over the artboard gradient. The frame has // non-interactive overlay drawn over the artboard gradient. The frame has
@ -524,10 +524,6 @@ export default function TradeInV2Page() {
// (#2046) — known profile fields fall back to username / brand ?? role / "" // (#2046) — known profile fields fall back to username / brand ?? role / ""
// when absent (never invented). undefined while loading → TopNav «Гость». // when absent (never invented). undefined while loading → TopNav «Гость».
const me = useMe(); const me = useMe();
// #2555: session-logout (POST /api/v1/auth/logout + local /me-cache
// invalidate + redirect на /login) — replaces the legacy basic_auth-cache
// -bust `logout()` for the v2 TopNav (new /login-form users).
const logoutMutation = useLogout();
// Dashboard sub-hooks — each resolves independently; failure degrades its // Dashboard sub-hooks — each resolves independently; failure degrades its
// section via the mappers (null input) rather than blanking the page. // section via the mappers (null input) rather than blanking the page.
@ -977,7 +973,7 @@ export default function TradeInV2Page() {
onNavigate={setNav} onNavigate={setNav}
reports={reportsCount ?? 0} reports={reportsCount ?? 0}
user={topNavUser} user={topNavUser}
onLogout={() => logoutMutation.mutate()} onLogout={logout}
/> />
</nav> </nav>
<main <main

View file

@ -10,17 +10,9 @@
* RBAC config (`auth/roles.yaml`) использует абсолютные пути сайта * RBAC config (`auth/roles.yaml`) использует абсолютные пути сайта
* (`/trade-in/**`, `/trade-in/api/v1/admin/**`), поэтому перед проверкой * (`/trade-in/**`, `/trade-in/api/v1/admin/**`), поэтому перед проверкой
* isPathAllowed мы префиксим pathname через NEXT_PUBLIC_BASE_PATH. * isPathAllowed мы префиксим pathname через NEXT_PUBLIC_BASE_PATH.
*
* #2555 login redirect: `router.push()` (как и `usePathname()`) работает в
* пространстве путей БЕЗ basePath Next сам префиксит basePath на навигации
* (см. `next.config.ts` комментарий `basePath`). Поэтому `next=` в query
* строится из `rawPath` (БЕЗ basePath), а не `absolutePath` иначе
* `/login/page.tsx` сделал бы `router.push("/trade-in/history")`, и Next
* задвоил бы префикс в `/trade-in/trade-in/history`.
*/ */
import { useRouter, usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { NoAccessScreen } from "@/components/auth/NoAccessScreen"; import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
import { HTTPError } from "@/lib/api"; import { HTTPError } from "@/lib/api";
@ -37,7 +29,6 @@ interface RouteGuardProps {
export function RouteGuard({ children }: RouteGuardProps) { export function RouteGuard({ children }: RouteGuardProps) {
const rawPath = usePathname() ?? "/"; const rawPath = usePathname() ?? "/";
const router = useRouter();
// Абсолютный путь сайта: BASE_PATH + rawPath. Аккуратно с двойным слэшем // Абсолютный путь сайта: BASE_PATH + rawPath. Аккуратно с двойным слэшем
// на `/`: `BASE_PATH = "/trade-in"` + `"/"` → `/trade-in/` (ок). // на `/`: `BASE_PATH = "/trade-in"` + `"/"` → `/trade-in/` (ок).
const absolutePath = BASE_PATH const absolutePath = BASE_PATH
@ -45,51 +36,21 @@ export function RouteGuard({ children }: RouteGuardProps) {
: rawPath; : rawPath;
const { data, isLoading, error } = useMe(); const { data, isLoading, error } = useMe();
// #2555: /login сам себя не гейтит — иначе редирект-петля (401 на /me →
// редирект на /login → RouteGuard на /login опять видит 401 → редирект…).
const isLoginPage = rawPath === "/login";
// Prod-only: сессия истекла/отсутствует → уводим на логин вместо старого
// NoAccessScreen variant="session". Редирект — побочный эффект (нельзя
// router.push во время рендера), поэтому useEffect; пока он не сработал,
// рендерим null (см. return ниже), чтобы не мигал старый contents.
const shouldRedirectToLogin =
!isLoginPage &&
process.env.NODE_ENV === "production" &&
error instanceof HTTPError &&
error.status === 401;
useEffect(() => {
if (!shouldRedirectToLogin) return;
// PR #2562 review finding 3: deep-links carry их state в query (`/v2?id=
// <uuid>` — см. next.config.ts redirect comment про restore-by-id). Без
// `window.location.search` юзер, чья сессия истекла mid-session на такой
// ссылке, после логина попадал бы на голый `/v2` и терял отчёт. Effect
// — гарантированно client-side (useEffect тело никогда не бежит на SSR),
// поэтому `window` тут безопасен без typeof-guard.
const next = `${rawPath}${window.location.search}`;
router.push(`/login?next=${encodeURIComponent(next)}`);
}, [shouldRedirectToLogin, rawPath, router]);
// #801: preview-страница самодостаточна (свой QueryClient с фейковым me), // #801: preview-страница самодостаточна (свой QueryClient с фейковым me),
// RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено. // RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено.
if (ENABLE_PREVIEW && rawPath.startsWith("/ui-preview")) { if (ENABLE_PREVIEW && rawPath.startsWith("/ui-preview")) {
return <>{children}</>; return <>{children}</>;
} }
if (isLoginPage) {
return <>{children}</>;
}
if (isLoading) return null; if (isLoading) return null;
if (error instanceof HTTPError && error.status === 401) { if (error instanceof HTTPError && error.status === 401) {
// Dev without Caddy: 401 is normal, mount the app so local dev works. // Dev without Caddy: 401 is normal, mount the app so local dev works.
// Prod: mounting children on 401 causes TanStack Query re-subscribe storm
// (each new observer on errored query triggers a refetch). Show session screen
// instead — prevents the subtree from mounting, kills the loop.
if (process.env.NODE_ENV !== "production") return <>{children}</>; if (process.env.NODE_ENV !== "production") return <>{children}</>;
// Prod: редирект уже запущен эффектом выше — ничего не рендерим, пока return <NoAccessScreen variant="session" />;
// навигация не завершится (mounting children on 401 causes TanStack
// Query re-subscribe storm, см. историю до #2555 в git blame).
return null;
} }
if (error instanceof HTTPError && error.status === 403) { if (error instanceof HTTPError && error.status === 403) {

View file

@ -1,40 +0,0 @@
"use client";
/**
* #2555: session-logout POST /api/v1/auth/logout (revoke DB session +
* очистка httponly cookie tradein_session), затем чистим локальный
* TanStack Query /me-кэш и уходим на /login.
*
* NB: это НЕ замена legacy `@/lib/logout.ts` (Caddy basic_auth cache-bust +
* hard reload) тот остаётся для страниц/пользователей на старом
* trusted-header механизме (см. `app.core.rbac` dual-mode resolver).
* useLogout для юзеров, залогиненных через новую /login форму (#2552).
*
* Backend logout best-effort по духу (revoke конкретной сессии), поэтому
* локальный logout (кэш + редирект) выполняется в `onSettled`, а не только
* `onSuccess`: сетевой сбой / уже-протухшая сессия не должны запирать юзера
* на странице без возможности разлогиниться.
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { ME_QUERY_KEY } from "@/lib/useMe";
async function logoutRequest(): Promise<void> {
await apiFetch<{ ok: boolean }>("/api/v1/auth/logout", { method: "POST" });
}
export function useLogout() {
const queryClient = useQueryClient();
const router = useRouter();
return useMutation({
mutationFn: logoutRequest,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
router.push("/login");
},
});
}

View file

@ -14,18 +14,7 @@ import { useQuery } from "@tanstack/react-query";
import { apiFetchWithStatus, HTTPError } from "@/lib/api"; import { apiFetchWithStatus, HTTPError } from "@/lib/api";
// #2555: session-auth (POST /api/v1/auth/login) вводит новые роли export type Role = "admin" | "pilot" | "expired";
// admin|manager|employee. Legacy Caddy trusted-header роли (pilot|analyst|
// expired) остаются — backend `/api/v1/me` может отдать любую из обеих
// групп в зависимости от того, каким механизмом пришёл юзер (dual-mode
// resolver, см. `tradein-mvp/backend/app/core/rbac.py`).
export type Role =
| "admin"
| "manager"
| "employee"
| "pilot"
| "analyst"
| "expired";
export interface UserScope { export interface UserScope {
username: string; username: string;