Compare commits
No commits in common. "main" and "feat/tradein-auth-core" have entirely different histories.
main
...
feat/trade
9 changed files with 8 additions and 2030 deletions
|
|
@ -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]
|
|
||||||
|
|
@ -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"])
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue