fix(tradein/team): revoke сессий при смене пароля + Origin-check + батч-квоты (#2554)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m56s

Deep-review PR #2563:
- HIGH: PATCH new_password не ревокал старые сессии сотрудника — sliding-refresh
  продлевал скомпрометированную/чужую сессию бесконечно. Теперь revoke_user_sessions
  вызывается при is_active=False ИЛИ new_password (self-lockout невозможен —
  _fetch_employee_row фильтрует role='employee').
- Medium1: Origin/Referer-check (защита CSRF) на POST/PATCH team-роутах против
  settings.cors_origins; оба заголовка отсутствуют (curl-смоук внутри контейнера) —
  пропускаем, не ломаем прод-смоук.
- Medium2: батч-квота для GET /employees (_batch_quota_status) — было 2N+3 SQL-запросов
  на N сотрудников, стало 5 плоско (замерено: N=10 -> 5 запросов). Добавлена пагинация
  limit(default 50, max 200)/offset.
- Заодно: статические WHERE-ветки вместо f-string-сборки в list_employees;
  monthly_limit теперь сбрасывает account_quota_overrides.unlimited=false и не
  затирает существующий note.
This commit is contained in:
bot-backend 2026-07-30 21:23:32 +03:00
parent 712c56f456
commit 4ac3971061
2 changed files with 346 additions and 39 deletions

View file

@ -25,6 +25,7 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Annotated, Any from typing import Annotated, Any
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import text from sqlalchemy import text
@ -32,6 +33,7 @@ from sqlalchemy.engine import RowMapping
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.auth import get_role
from app.core.config import settings from app.core.config import settings
from app.core.db import get_db from app.core.db import get_db
from app.core.password import hash_password from app.core.password import hash_password
@ -92,6 +94,47 @@ async def current_team_actor(
) )
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 # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -131,14 +174,22 @@ def _authorize_employee(actor: TeamActor, row: RowMapping | None) -> RowMapping:
def _upsert_quota_override( def _upsert_quota_override(
db: Session, username: str, monthly_limit: int, actor_username: str db: Session, username: str, monthly_limit: int, actor_username: str
) -> None: ) -> None:
"""Upsert персонального лимита. Явная установка monthly_limit — сигнал "хочу
numeric-квоту", поэтому ВСЕГДА сбрасывает `unlimited=false` (иначе лимит может
молча не применяться прежний unlimited-грант выигрывал бы у нового limit).
`note` НЕ затирается, если уже задан (`COALESCE`): не перезаписываем
человеко-читаемую причину прошлого гранта (напр. "пилот, грант ...") молча
сгенерированной строкой; note проставляется только при первом upsert записи.
"""
db.execute( db.execute(
text( text(
""" """
INSERT INTO account_quota_overrides (username, monthly_limit, note) INSERT INTO account_quota_overrides (username, monthly_limit, unlimited, note)
VALUES (:username, CAST(:monthly_limit AS integer), :note) VALUES (:username, CAST(:monthly_limit AS integer), false, :note)
ON CONFLICT (username) DO UPDATE SET ON CONFLICT (username) DO UPDATE SET
monthly_limit = EXCLUDED.monthly_limit, monthly_limit = EXCLUDED.monthly_limit,
note = EXCLUDED.note, unlimited = false,
note = COALESCE(account_quota_overrides.note, EXCLUDED.note),
updated_at = now() updated_at = now()
""" """
), ),
@ -150,6 +201,83 @@ def _upsert_quota_override(
) )
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: def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut:
return EmployeeOut( return EmployeeOut(
id=row["id"], id=row["id"],
@ -174,6 +302,7 @@ async def create_employee(
body: EmployeeCreateRequest, body: EmployeeCreateRequest,
actor: Annotated[TeamActor, Depends(current_team_actor)], actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_origin_check: Annotated[None, Depends(_require_same_origin)],
) -> EmployeeOut: ) -> EmployeeOut:
"""Создать сотрудника. Роль всегда `employee`. """Создать сотрудника. Роль всегда `employee`.
@ -277,12 +406,18 @@ async def update_employee(
body: EmployeeUpdateRequest, body: EmployeeUpdateRequest,
actor: Annotated[TeamActor, Depends(current_team_actor)], actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
_origin_check: Annotated[None, Depends(_require_same_origin)],
) -> EmployeeOut: ) -> EmployeeOut:
"""Частичное обновление сотрудника — block/unblock, лимит, профиль, пароль. """Частичное обновление сотрудника — block/unblock, лимит, профиль, пароль.
manager может патчить ТОЛЬКО своих (manager_id == actor.user_id), иначе 404. manager может патчить ТОЛЬКО своих (manager_id == actor.user_id), иначе 404.
При is_active=False обязательно revoke всех сессий (иначе блокировка не При is_active=False ИЛИ смене пароля (new_password) обязательно revoke всех
подействует до истечения TTL текущей сессии сотрудника). сессий (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 = _fetch_employee_row(db, employee_id)
row = _authorize_employee(actor, row) row = _authorize_employee(actor, row)
@ -320,10 +455,12 @@ async def update_employee(
if body.monthly_limit is not None: if body.monthly_limit is not None:
_upsert_quota_override(db, row["username"], body.monthly_limit, actor.username) _upsert_quota_override(db, row["username"], body.monthly_limit, actor.username)
if body.is_active is False: if body.is_active is False or body.new_password is not None:
# Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions # Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions
# коммитит сам (см. app.services.auth_session), это флашит и наш # коммитит сам (см. app.services.auth_session), это флашит и наш
# предшествующий UPDATE/quota-upsert в той же сессии. # предшествующий UPDATE/quota-upsert в той же сессии. Self-lockout
# невозможен: _fetch_employee_row фильтрует role='employee', actor
# (admin|manager) никогда не может патчить сам себя через этот роут.
revoke_user_sessions(db, employee_id) revoke_user_sessions(db, employee_id)
db.commit() db.commit()
@ -381,44 +518,68 @@ async def update_employee(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Два статических варианта 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]) @router.get("/employees", response_model=list[EmployeeOut])
async def list_employees( async def list_employees(
actor: Annotated[TeamActor, Depends(current_team_actor)], actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
manager_id: Annotated[int | None, Query()] = None, 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]: ) -> list[EmployeeOut]:
"""Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=.""" """Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=.
params: dict[str, Any] = {}
where = "WHERE role = 'employee'" Квота ОДИН батч-запрос на всю страницу (`_batch_quota_status`), не N+1
(Medium2, review PR #2563: было 2N+3 SQL-запросов на N сотрудников).
"""
if actor.role == "manager": if actor.role == "manager":
where += " AND manager_id = :manager_id" rows = (
params["manager_id"] = actor.user_id db.execute(
elif manager_id is not None: _LIST_EMPLOYEES_BY_MANAGER_SQL,
where += " AND manager_id = :manager_id" {"manager_id": actor.user_id, "limit": limit, "offset": offset},
params["manager_id"] = manager_id )
.mappings()
rows = ( .all()
db.execute( )
text( elif manager_id is not None:
f""" rows = (
SELECT id, username, display_name, org_name, email, is_active, db.execute(
manager_id, created_at _LIST_EMPLOYEES_BY_MANAGER_SQL,
FROM tradein_users {"manager_id": manager_id, "limit": limit, "offset": offset},
{where} )
ORDER BY created_at DESC .mappings()
""" .all()
), )
params, else:
rows = (
db.execute(_LIST_EMPLOYEES_ALL_SQL, {"limit": limit, "offset": offset}).mappings().all()
) )
.mappings()
.all()
)
result: list[EmployeeOut] = [] quota_by_username = _batch_quota_status(db, [row["username"] for row in rows])
for row in rows: return [_employee_out(row, quota_by_username[row["username"]]) for row in rows]
quota = account_quota.get_status(db, row["username"])
result.append(_employee_out(row, quota))
return result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -48,6 +48,7 @@ class _Store:
self.estimates: dict[str, dict[str, Any]] = {} # estimate_id -> result fields self.estimates: dict[str, dict[str, Any]] = {} # estimate_id -> result fields
self.events: list[dict[str, Any]] = [] # user_events rows (history source) self.events: list[dict[str, Any]] = [] # user_events rows (history source)
self._next_id = 1 self._next_id = 1
self.query_count = 0 # db.execute() calls — N+1 regression guard (review PR #2563)
def add_user( def add_user(
self, self,
@ -169,6 +170,7 @@ class _FakeDB:
sql = str(stmt) sql = str(stmt)
p = params or {} p = params or {}
s = self.store s = self.store
s.query_count += 1
# ---- tradein_sessions ---- # ---- tradein_sessions ----
if "INSERT INTO tradein_sessions" in sql: if "INSERT INTO tradein_sessions" in sql:
@ -258,6 +260,8 @@ class _FakeDB:
if "manager_id" in p: if "manager_id" in p:
rows = [u for u in rows if u["manager_id"] == p["manager_id"]] rows = [u for u in rows if u["manager_id"] == p["manager_id"]]
rows = sorted(rows, key=lambda u: u["created_at"], reverse=True) 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( return _Result(
[ [
{ {
@ -315,15 +319,29 @@ class _FakeDB:
user["password_hash"] = p["password_hash"] user["password_hash"] = p["password_hash"]
return _Result([]) return _Result([])
# ---- account_quota_overrides upsert ---- # ---- 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: 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"]] = { s.quota_overrides[p["username"]] = {
"monthly_limit": p["monthly_limit"], "monthly_limit": p["monthly_limit"],
"unlimited": False, "unlimited": False,
"note": p["note"], "note": preserved_note,
} }
return _Result([]) 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: if "SELECT unlimited FROM account_quota_overrides" in sql:
override = s.quota_overrides.get(p["u"]) override = s.quota_overrides.get(p["u"])
return _Result([{"unlimited": override["unlimited"]}] if override else []) return _Result([{"unlimited": override["unlimited"]}] if override else [])
@ -332,7 +350,16 @@ class _FakeDB:
override = s.quota_overrides.get(p["u"]) override = s.quota_overrides.get(p["u"])
return _Result([{"monthly_limit": override["monthly_limit"]}] if override else []) return _Result([{"monthly_limit": override["monthly_limit"]}] if override else [])
# ---- account_estimate_usage ---- # ---- 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: if "SELECT used FROM account_estimate_usage" in sql:
used = s.usage.get((p["u"], p["p"])) used = s.usage.get((p["u"], p["p"]))
return _Result([{"used": used}] if used is not None else []) return _Result([{"used": used}] if used is not None else [])
@ -619,6 +646,28 @@ def test_block_employee_revokes_sessions(client: TestClient, store: _Store) -> N
assert any(e["event_type"] == "employee_blocked" for e in _EVENTS) 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: def test_unblock_employee_event(client: TestClient, store: _Store) -> None:
mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
emp_id = store.add_user( emp_id = store.add_user(
@ -700,6 +749,103 @@ def test_admin_sees_all_employees_and_filters_by_manager(client: TestClient, sto
assert {e["username"] for e in resp_filtered.json()} == {"emp_a"} 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 # GET /employees/{id}/history
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------