Files
vidconf/backend/tests/test_teams_api.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

57 lines
1.8 KiB
Python

"""Интеграционные тесты `/api/v1/teams`: справочник команд для
выбора в профиле — доступен любому аутентифицированному пользователю (не гейтится
`registration_team_choice`, в отличие от `/auth/registration-options`; не требует
роли admin, в отличие от `/admin/teams`).
"""
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) -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Teams API Tester",
password_hash=await hash_password("password123"),
email_verified=True,
)
session.add(user)
await session.flush()
return user
async def _make_team(session: AsyncSession) -> Team:
team = Team(name=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}"}
async def test_regular_user_gets_full_team_list(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
team = await _make_team(db_session)
await db_session.commit()
response = await client.get("/api/v1/teams", headers=_auth_headers(user))
assert response.status_code == 200, response.text
item = next(i for i in response.json() if i["id"] == str(team.id))
assert item["name"] == team.name
async def test_list_teams_requires_authentication(client: httpx.AsyncClient) -> None:
response = await client.get("/api/v1/teams")
assert response.status_code == 401