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

93 lines
3.4 KiB
Python

"""Юнит-тесты модели `ConferenceInvitee`: CHECK «ровно одна identity» и частичные
UNIQUE-индексы (ADR-003, `docs/architecture/adr/003-conference-invitees.md`).
"""
import uuid
import pytest
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import hash_password
from models.conference import Conference
from models.invitee import ConferenceInvitee
from models.user import User
from services.conference_ids import generate_number, generate_slug
async def _make_user(session: AsyncSession) -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Invitee Tester",
password_hash=await hash_password("password123"),
email_verified=True,
)
session.add(user)
await session.flush()
return user
async def _make_conference(
session: AsyncSession, *, owner_id: uuid.UUID | None = None
) -> Conference:
conference = Conference(
number=generate_number(), slug=generate_slug(), owner_id=owner_id, status="scheduled"
)
session.add(conference)
await session.flush()
return conference
async def test_neither_user_id_nor_email_violates_check(db_session: AsyncSession) -> None:
conference = await _make_conference(db_session)
db_session.add(ConferenceInvitee(conference_id=conference.id))
with pytest.raises(IntegrityError):
await db_session.commit()
async def test_both_user_id_and_email_violates_check(db_session: AsyncSession) -> None:
conference = await _make_conference(db_session)
invitee_user = await _make_user(db_session)
await db_session.commit()
db_session.add(
ConferenceInvitee(
conference_id=conference.id, user_id=invitee_user.id, email="x@example.com"
)
)
with pytest.raises(IntegrityError):
await db_session.commit()
async def test_duplicate_user_id_on_same_conference_violates_unique(
db_session: AsyncSession,
) -> None:
conference = await _make_conference(db_session)
invitee_user = await _make_user(db_session)
db_session.add(ConferenceInvitee(conference_id=conference.id, user_id=invitee_user.id))
await db_session.commit()
db_session.add(ConferenceInvitee(conference_id=conference.id, user_id=invitee_user.id))
with pytest.raises(IntegrityError):
await db_session.commit()
async def test_duplicate_email_case_insensitive_violates_unique(db_session: AsyncSession) -> None:
conference = await _make_conference(db_session)
unique_email = f"guest-{uuid.uuid4()}@example.com"
db_session.add(ConferenceInvitee(conference_id=conference.id, email=unique_email))
await db_session.commit()
db_session.add(ConferenceInvitee(conference_id=conference.id, email=unique_email.upper()))
with pytest.raises(IntegrityError):
await db_session.commit()
async def test_same_email_on_different_conferences_is_allowed(db_session: AsyncSession) -> None:
conference_a = await _make_conference(db_session)
conference_b = await _make_conference(db_session)
shared_email = f"shared-{uuid.uuid4()}@example.com"
db_session.add(ConferenceInvitee(conference_id=conference_a.id, email=shared_email))
db_session.add(ConferenceInvitee(conference_id=conference_b.id, email=shared_email))
await db_session.commit() # не должно бросить — разные конференции