Files
vidconf/backend/tests/test_admin_teams.py
Max Ronzhin 84b7f807f7 fix(auth): проверка пароля больше не блокирует весь backend
На нагрузочном тесте 31.07.2026 около 70 человек заходили одновременно.
Вход развалился: p95 `/api/v1/auth/token` — 7.28 с, p95 `guest-join` —
7.06 с, в БД 33 соединения `idle in transaction` при ОДНОМ активном
запросе. Люди попадали внутрь с пятой-десятой попытки, часть не попала
вовсе. Медиа при этом работало штатно: 30 участников с 27 камерами в
следующем окне прошли без единого лага.

Причина — argon2 считался синхронно внутри async-обработчика. Замер на
боевом сервере: 95–155 мс на одну проверку, и всё это время event loop
процесса стоит целиком. Транзакция БД к тому моменту уже открыта
(`get_by_email` сделал SELECT), поэтому соединение висело без работы, пул
из 40 выбирался, и отказы получали совершенно посторонние ручки — включая
вход в конференцию, где никакого пароля не проверялось.

Что изменилось:
- `hash_password`/`verify_password` стали асинхронными и считаются в пуле
  потоков (`asyncio.to_thread`). argon2-cffi освобождает GIL, поэтому
  проверки идут по-настоящему параллельно;
- параметры argon2id заменены с дефолтов библиотеки (t=3, m=64 МБ, p=4) на
  рекомендацию OWASP (t=2, m=19 МБ, p=1): 95 мс → 42 мс. Отдельно важен
  `parallelism`: при p=4 одна проверка пароля занимала все четыре ядра
  сервера — те же, на которых работает LiveKit;
- добавлен `needs_rehash`: существующие хэши проверяются как прежде
  (параметры зашиты в саму строку) и лениво перевыпускаются при первом
  успешном входе.

Расчёт по замерам: пачка из 70 логинов — 6.7–10.9 с блокировки против
~0.36 с без неё.

Тесты: event loop продолжает тикать во время проверки; 8 параллельных
проверок укладываются заметно быстрее восьми последовательных; хэш со
старыми параметрами принимается и перевыпускается при входе.
2026-08-01 23:19:52 +03:00

300 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Интеграционные тесты справочника команд `/api/v1/admin/teams` и привязки `users.team_id`.
Покрывает: CRUD команд, 409 на дубль имени, 403 не-админу на новые
эндпоинты, назначение/снятие команды пользователю (в т.ч. явный `null`),
404 на несуществующую команду, обнуление `team_id` при удалении команды,
разрешённое самоизменение `team_id` (в отличие от `role`/`is_blocked`).
"""
import uuid
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import create_access_token, hash_password
from models.team import Team
from models.user import User
async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Team API Tester",
password_hash=await hash_password("password123"),
email_verified=True,
role=role,
)
session.add(user)
await session.flush()
return user
async def _make_team(session: AsyncSession, *, name: str | None = None) -> Team:
team = Team(name=name or f"Team {uuid.uuid4()}")
session.add(team)
await session.flush()
return team
def _auth_headers(user: User) -> dict[str, str]:
token = create_access_token(user.id, user.role)
return {"Authorization": f"Bearer {token}"}
# --- 403 не-админу -------------------------------------------------------------------
async def test_team_endpoints_forbidden_for_non_admin(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
team = await _make_team(db_session)
await db_session.commit()
headers = _auth_headers(user)
requests = [
("GET", "/api/v1/admin/teams", None),
("POST", "/api/v1/admin/teams", {"name": "X"}),
("PATCH", f"/api/v1/admin/teams/{team.id}", {"name": "Y"}),
("DELETE", f"/api/v1/admin/teams/{team.id}", None),
]
for method, path, body in requests:
response = await client.request(method, path, json=body, headers=headers)
assert response.status_code == 403, f"{method} {path} -> {response.status_code}"
# --- CRUD команд ---------------------------------------------------------------------
async def test_create_and_list_teams_sorted_by_name(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Список отсортирован по названию; сравниваем взаимный порядок и наличие
своих (уникальных по суффиксу) команд, а не полный список — в общей
dev-БД могут быть и другие, легитимные команды разработчика."""
admin = await _make_user(db_session, role="admin")
suffix = uuid.uuid4().hex[:8]
name_alpha, name_beta, name_zeta = f"Alpha-{suffix}", f"Beta-{suffix}", f"Zeta-{suffix}"
await _make_team(db_session, name=name_zeta)
await _make_team(db_session, name=name_alpha)
await db_session.commit()
response = await client.post(
"/api/v1/admin/teams", json={"name": name_beta}, headers=_auth_headers(admin)
)
assert response.status_code == 201, response.text
created = response.json()
assert created["name"] == name_beta
assert "id" in created and "created_at" in created
list_response = await client.get("/api/v1/admin/teams", headers=_auth_headers(admin))
assert list_response.status_code == 200
body = list_response.json()
names = [item["name"] for item in body["items"]]
assert names == sorted(names)
assert name_alpha in names and name_beta in names and name_zeta in names
assert names.index(name_alpha) < names.index(name_beta) < names.index(name_zeta)
assert body["total"] == len(body["items"])
async def test_create_team_strips_name(client: httpx.AsyncClient, db_session: AsyncSession) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.post(
"/api/v1/admin/teams", json={"name": " Padded "}, headers=_auth_headers(admin)
)
assert response.status_code == 201, response.text
assert response.json()["name"] == "Padded"
async def test_create_team_duplicate_name_returns_409(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
name = f"Duplicate {uuid.uuid4()}"
await _make_team(db_session, name=name)
await db_session.commit()
response = await client.post(
"/api/v1/admin/teams", json={"name": name}, headers=_auth_headers(admin)
)
assert response.status_code == 409
async def test_update_team_renames(client: httpx.AsyncClient, db_session: AsyncSession) -> None:
admin = await _make_user(db_session, role="admin")
team = await _make_team(db_session, name=f"Old Name {uuid.uuid4()}")
await db_session.commit()
new_name = f"New Name {uuid.uuid4()}"
response = await client.patch(
f"/api/v1/admin/teams/{team.id}", json={"name": new_name}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
assert response.json()["name"] == new_name
async def test_update_team_duplicate_name_returns_409(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
name_a = f"Team A {uuid.uuid4()}"
team_a = await _make_team(db_session, name=name_a)
team_b = await _make_team(db_session, name=f"Team B {uuid.uuid4()}")
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/teams/{team_b.id}",
json={"name": name_a},
headers=_auth_headers(admin),
)
assert response.status_code == 409
assert team_a.name == name_a
async def test_update_team_unknown_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/teams/{uuid.uuid4()}", json={"name": "X"}, headers=_auth_headers(admin)
)
assert response.status_code == 404
async def test_delete_team_unknown_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.delete(
f"/api/v1/admin/teams/{uuid.uuid4()}", headers=_auth_headers(admin)
)
assert response.status_code == 404
async def test_delete_team_clears_user_team_id(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
team = await _make_team(db_session)
member = await _make_user(db_session)
member.team_id = team.id
await db_session.commit()
response = await client.delete(f"/api/v1/admin/teams/{team.id}", headers=_auth_headers(admin))
assert response.status_code == 204
await db_session.refresh(member)
assert member.team_id is None
# --- Привязка пользователя к команде ---------------------------------------------------
async def test_patch_user_assigns_team(client: httpx.AsyncClient, db_session: AsyncSession) -> None:
admin = await _make_user(db_session, role="admin")
target = await _make_user(db_session)
team = await _make_team(db_session)
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{target.id}",
json={"team_id": str(team.id)},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
assert response.json()["team_id"] == str(team.id)
async def test_patch_user_clears_team_with_explicit_null(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
team = await _make_team(db_session)
target = await _make_user(db_session)
target.team_id = team.id
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{target.id}",
json={"team_id": None},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
assert response.json()["team_id"] is None
async def test_patch_user_missing_team_field_keeps_current_team(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
team = await _make_team(db_session)
target = await _make_user(db_session)
target.team_id = team.id
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{target.id}",
json={"role": "admin"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
assert response.json()["team_id"] == str(team.id)
async def test_patch_user_unknown_team_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
target = await _make_user(db_session)
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{target.id}",
json={"team_id": str(uuid.uuid4())},
headers=_auth_headers(admin),
)
assert response.status_code == 404
async def test_admin_can_change_own_team_id(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
team = await _make_team(db_session)
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{admin.id}",
json={"team_id": str(team.id)},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
assert response.json()["team_id"] == str(team.id)
async def test_admin_still_forbidden_to_change_own_role_and_block(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{admin.id}",
json={"is_blocked": True},
headers=_auth_headers(admin),
)
assert response.status_code == 409
response = await client.patch(
f"/api/v1/admin/users/{admin.id}",
json={"role": "user"},
headers=_auth_headers(admin),
)
assert response.status_code == 409