fix(tradein/team): устойчивая сортировка списка + семантика unlimited в батч-квотах (#2554) #2566
2 changed files with 150 additions and 11 deletions
|
|
@ -205,10 +205,25 @@ def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str
|
||||||
"""Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса
|
"""Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса
|
||||||
вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563).
|
вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563).
|
||||||
|
|
||||||
Семантика ИДЕНТИЧНА `account_quota.is_unlimited`/`user_limit`/`get_status`:
|
Семантика ИДЕНТИЧНА `account_quota.is_unlimited`/`user_limit`/`get_status`
|
||||||
unlimited = admin-роль (roles.yaml, in-memory, без похода в БД) ИЛИ
|
(follow-up review PR #2563 п.2 — предыдущая версия расходилась: батч ВСЕГДА
|
||||||
`account_quota_overrides.unlimited=true`; limit = override.monthly_limit,
|
читал `account_quota_overrides.unlimited`, а `is_unlimited` — ТОЛЬКО для
|
||||||
иначе глобальный `account_quota.MONTHLY_LIMIT`.
|
username, присутствующего в roles.yaml):
|
||||||
|
- username НЕ в roles.yaml (`get_role` → KeyError) → unlimited=False ВСЕГДА,
|
||||||
|
`account_quota_overrides.unlimited` даже не проверяется (roles.yaml —
|
||||||
|
источник правды "кто вообще может быть unlimited", override — "у кого
|
||||||
|
именно из известных roles.yaml-юзеров"). Сегодня недостижимо для DB-only
|
||||||
|
сотрудников team-API (`_upsert_quota_override` всегда пишет
|
||||||
|
`unlimited=false`), но станет достижимым при ручном UPDATE
|
||||||
|
`account_quota_overrides` или расширении roles.yaml — расхождение с
|
||||||
|
реальным enforcement (`check_and_raise`/`increment`, тот же `is_unlimited`)
|
||||||
|
было бы честной ложью в списке: "без лимита", который движок всё равно
|
||||||
|
считает.
|
||||||
|
- username в roles.yaml и role == admin → unlimited=True (без похода в БД).
|
||||||
|
- username в roles.yaml, role != admin → unlimited = override.unlimited.
|
||||||
|
limit = override.monthly_limit (читается для ЛЮБОГО username, без gate по
|
||||||
|
roles.yaml — так же ведёт себя `account_quota.user_limit`), иначе глобальный
|
||||||
|
`account_quota.MONTHLY_LIMIT`.
|
||||||
"""
|
"""
|
||||||
if not usernames:
|
if not usernames:
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -250,10 +265,17 @@ def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str
|
||||||
for username in usernames:
|
for username in usernames:
|
||||||
override = override_by_username.get(username)
|
override = override_by_username.get(username)
|
||||||
try:
|
try:
|
||||||
is_admin_role = get_role(username) == "admin"
|
role = get_role(username)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
is_admin_role = False
|
role = None
|
||||||
unlimited = is_admin_role or bool(override is not None and override["unlimited"])
|
if role == "admin":
|
||||||
|
unlimited = True
|
||||||
|
elif role is not None:
|
||||||
|
unlimited = bool(override is not None and override["unlimited"])
|
||||||
|
else:
|
||||||
|
# username не в roles.yaml — is_unlimited() короткое замыкание на
|
||||||
|
# False, override НЕ проверяется (см. докстринг выше).
|
||||||
|
unlimited = False
|
||||||
limit = (
|
limit = (
|
||||||
int(override["monthly_limit"])
|
int(override["monthly_limit"])
|
||||||
if override is not None and override["monthly_limit"] is not None
|
if override is not None and override["monthly_limit"] is not None
|
||||||
|
|
@ -521,12 +543,21 @@ async def update_employee(
|
||||||
# Два статических варианта WHERE (НЕ f-string/динамическая сборка — Medium/
|
# Два статических варианта WHERE (НЕ f-string/динамическая сборка — Medium/
|
||||||
# "заодно" review PR #2563: значения биндятся параметрами и без того безопасны,
|
# "заодно" review PR #2563: значения биндятся параметрами и без того безопасны,
|
||||||
# но статические ветки не провоцируют будущие правки в сторону конкатенации SQL).
|
# но статические ветки не провоцируют будущие правки в сторону конкатенации SQL).
|
||||||
|
#
|
||||||
|
# ORDER BY created_at DESC, id DESC — тай-брейкер по `id` ОБЯЗАТЕЛЕН (follow-up
|
||||||
|
# review PR #2563 п.1): `created_at DEFAULT now()` — время ТРАНЗАКЦИИ, а bulk-seed
|
||||||
|
# (#2557) вставляет много юзеров одной транзакцией → идентичный timestamp у N строк.
|
||||||
|
# Без тай-брейкера порядок между страницами (LIMIT/OFFSET) на PostgreSQL для
|
||||||
|
# строк-«близнецов» не гарантирован — сотрудники пропадали/дублировались бы при
|
||||||
|
# постраничном листании. `id` монотонно растёт (BIGINT IDENTITY) — детерминированный
|
||||||
|
# tie-break без доп. индекса (созданные позже = бОльший id, тот же порядок что и
|
||||||
|
# намерение DESC-сортировки по времени).
|
||||||
_LIST_EMPLOYEES_BY_MANAGER_SQL = text(
|
_LIST_EMPLOYEES_BY_MANAGER_SQL = text(
|
||||||
"""
|
"""
|
||||||
SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at
|
SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at
|
||||||
FROM tradein_users
|
FROM tradein_users
|
||||||
WHERE role = 'employee' AND manager_id = :manager_id
|
WHERE role = 'employee' AND manager_id = :manager_id
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC, id DESC
|
||||||
LIMIT :limit OFFSET :offset
|
LIMIT :limit OFFSET :offset
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
@ -536,7 +567,7 @@ _LIST_EMPLOYEES_ALL_SQL = text(
|
||||||
SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at
|
SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at
|
||||||
FROM tradein_users
|
FROM tradein_users
|
||||||
WHERE role = 'employee'
|
WHERE role = 'employee'
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC, id DESC
|
||||||
LIMIT :limit OFFSET :offset
|
LIMIT :limit OFFSET :offset
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ class _Store:
|
||||||
display_name: str | None = None,
|
display_name: str | None = None,
|
||||||
org_name: str | None = None,
|
org_name: str | None = None,
|
||||||
email: str | None = None,
|
email: str | None = None,
|
||||||
|
created_at: datetime | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
uid = self._next_id
|
uid = self._next_id
|
||||||
self._next_id += 1
|
self._next_id += 1
|
||||||
|
|
@ -74,7 +75,7 @@ class _Store:
|
||||||
"org_name": org_name,
|
"org_name": org_name,
|
||||||
"email": email,
|
"email": email,
|
||||||
"is_active": is_active,
|
"is_active": is_active,
|
||||||
"created_at": datetime.now(UTC),
|
"created_at": created_at or datetime.now(UTC),
|
||||||
}
|
}
|
||||||
return uid
|
return uid
|
||||||
|
|
||||||
|
|
@ -259,7 +260,10 @@ class _FakeDB:
|
||||||
rows = [u for u in s.users.values() if u["role"] == "employee"]
|
rows = [u for u in s.users.values() if u["role"] == "employee"]
|
||||||
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)
|
# Mirrors real SQL `ORDER BY created_at DESC, id DESC` — `id` tiebreak
|
||||||
|
# is REQUIRED for deterministic paging when created_at ties (follow-up
|
||||||
|
# review PR #2563 п.1, bulk-seed #2557 inserts many rows in one tx).
|
||||||
|
rows = sorted(rows, key=lambda u: (u["created_at"], u["id"]), reverse=True)
|
||||||
offset, limit = p.get("offset", 0), p.get("limit", len(rows))
|
offset, limit = p.get("offset", 0), p.get("limit", len(rows))
|
||||||
rows = rows[offset : offset + limit]
|
rows = rows[offset : offset + limit]
|
||||||
return _Result(
|
return _Result(
|
||||||
|
|
@ -787,6 +791,51 @@ def test_list_employees_limit_max_200(client: TestClient, store: _Store) -> None
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_employees_pagination_stable_with_identical_created_at(
|
||||||
|
client: TestClient, store: _Store
|
||||||
|
) -> None:
|
||||||
|
"""Follow-up review PR #2563 п.1: `created_at DEFAULT now()` — время ТРАНЗАКЦИИ,
|
||||||
|
bulk-seed (#2557) вставляет много юзеров одной транзакцией → идентичный
|
||||||
|
timestamp у N+ строк. Без `id DESC` тай-брейкера порядок между страницами
|
||||||
|
на PostgreSQL для строк-«близнецов» не гарантирован — сотрудники пропадали/
|
||||||
|
дублировались бы при постраничном листании. Вставляем 5 сотрудников с
|
||||||
|
ОДИНАКОВЫМ created_at, листаем limit=2 постранично — объединение страниц
|
||||||
|
обязано дать полный набор без дублей и пропусков."""
|
||||||
|
store.add_user("admin1", hash_password("Secret123!"), role="admin")
|
||||||
|
mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
|
||||||
|
same_ts = datetime.now(UTC)
|
||||||
|
expected_usernames = set()
|
||||||
|
for i in range(5):
|
||||||
|
username = f"emp_tie_{i}"
|
||||||
|
store.add_user(
|
||||||
|
username,
|
||||||
|
hash_password("Secret123!"),
|
||||||
|
role="employee",
|
||||||
|
manager_id=mgr_id,
|
||||||
|
created_at=same_ts,
|
||||||
|
)
|
||||||
|
expected_usernames.add(username)
|
||||||
|
|
||||||
|
_login(client, "admin1", "Secret123!")
|
||||||
|
|
||||||
|
seen: list[str] = []
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
resp = client.get("/api/v1/team/employees", params={"limit": 2, "offset": offset})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
page = [e["username"] for e in resp.json()]
|
||||||
|
if not page:
|
||||||
|
break
|
||||||
|
seen.extend(page)
|
||||||
|
offset += 2
|
||||||
|
|
||||||
|
assert len(seen) == len(expected_usernames), (
|
||||||
|
f"page union has {len(seen)} entries (dupes or gaps), expected "
|
||||||
|
f"{len(expected_usernames)}: {seen}"
|
||||||
|
)
|
||||||
|
assert set(seen) == expected_usernames
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CSRF defense-in-depth — Origin/Referer check on state-changing team routes
|
# CSRF defense-in-depth — Origin/Referer check on state-changing team routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -846,6 +895,65 @@ def test_patch_employee_origin_mismatch_403(client: TestClient, store: _Store) -
|
||||||
assert store.users["emp_a"]["display_name"] != "hacked"
|
assert store.users["emp_a"]["display_name"] != "hacked"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _batch_quota_status unlimited semantics — must match account_quota.is_unlimited
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_quota_unlimited_ignored_for_non_roles_yaml_username(
|
||||||
|
client: TestClient, store: _Store
|
||||||
|
) -> None:
|
||||||
|
"""Follow-up review PR #2563 п.2: `account_quota.is_unlimited` short-circuits
|
||||||
|
to False for a username NOT in roles.yaml — it never even reads
|
||||||
|
`account_quota_overrides.unlimited`. The batch quota status used by
|
||||||
|
GET /employees must agree, or the list would show "unlimited" for a quota
|
||||||
|
that real enforcement (check_and_raise/increment, same is_unlimited) does
|
||||||
|
NOT honor — a misleading display. `emp_ghost_unlimited` is a fresh DB-only
|
||||||
|
username guaranteed absent from roles.yaml."""
|
||||||
|
store.add_user("admin1", hash_password("Secret123!"), role="admin")
|
||||||
|
mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
|
||||||
|
store.add_user(
|
||||||
|
"emp_ghost_unlimited", hash_password("Secret123!"), role="employee", manager_id=mgr_id
|
||||||
|
)
|
||||||
|
store.quota_overrides["emp_ghost_unlimited"] = {
|
||||||
|
"monthly_limit": 15,
|
||||||
|
"unlimited": True,
|
||||||
|
"note": "manual grant via SQL runbook (not through team-api)",
|
||||||
|
}
|
||||||
|
|
||||||
|
_login(client, "admin1", "Secret123!")
|
||||||
|
resp = client.get("/api/v1/team/employees")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
entry = next(e for e in resp.json() if e["username"] == "emp_ghost_unlimited")
|
||||||
|
# DB override says unlimited=true, but username is NOT in roles.yaml — real
|
||||||
|
# enforcement would never see it, so the list must NOT claim "unlimited".
|
||||||
|
assert entry["quota"]["unlimited"] is False
|
||||||
|
assert entry["quota"]["limit"] == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_quota_unlimited_honored_for_roles_yaml_username(
|
||||||
|
client: TestClient, store: _Store
|
||||||
|
) -> None:
|
||||||
|
"""Symmetric positive case: a username actually present in roles.yaml
|
||||||
|
(non-admin role) — `account_quota_overrides.unlimited=true` IS honored, same
|
||||||
|
as `account_quota.is_unlimited`. Uses `kopylov` — real prod pilot-role entry
|
||||||
|
in auth/roles.yaml (see app/core/auth.py module docstring)."""
|
||||||
|
store.add_user("admin1", hash_password("Secret123!"), role="admin")
|
||||||
|
mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
|
||||||
|
store.add_user("kopylov", hash_password("Secret123!"), role="employee", manager_id=mgr_id)
|
||||||
|
store.quota_overrides["kopylov"] = {
|
||||||
|
"monthly_limit": 999,
|
||||||
|
"unlimited": True,
|
||||||
|
"note": "existing prod grant",
|
||||||
|
}
|
||||||
|
|
||||||
|
_login(client, "admin1", "Secret123!")
|
||||||
|
resp = client.get("/api/v1/team/employees")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
entry = next(e for e in resp.json() if e["username"] == "kopylov")
|
||||||
|
assert entry["quota"]["unlimited"] is True
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /employees/{id}/history
|
# GET /employees/{id}/history
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue