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

451 lines
17 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.
"""Юнит-тесты `services/conferences.py`: retry при коллизии номера/slug, резолв,
постановка рассылки .ics-приглашений и инкремент `ics_sequence`.
"""
import uuid
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock
import pytest
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
import services.conferences as conferences_module
from core.security import hash_password
from models.conference import Conference
from models.user import User
from schemas.conferences import ConferenceCreateIn, ConferenceUpdateIn, InviteeIn
from services.conference_ids import generate_number, generate_slug
from services.conferences import ConferenceService, InviteeUserNotFoundError
FUTURE = datetime.now(UTC) + timedelta(days=3)
async def _make_user(session: AsyncSession, *, name: str = "Service Tester") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user=name,
password_hash=await hash_password("password123"),
email_verified=True,
)
session.add(user)
await session.flush()
return user
async def test_create_retries_on_number_collision_and_succeeds(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Первая попытка сталкивается с занятым номером, вторая — успевает с новым."""
owner = await _make_user(db_session)
existing = Conference(number="123456789", slug=generate_slug(), status="active")
db_session.add(existing)
await db_session.commit()
fresh_number = generate_number()
while fresh_number == "123456789":
fresh_number = generate_number()
attempts = iter(["123456789", fresh_number])
monkeypatch.setattr(conferences_module, "generate_number", lambda: next(attempts))
service = ConferenceService(db_session)
conference, join = await service.create(owner=owner, data=ConferenceCreateIn())
assert conference.number == fresh_number
assert join is not None
async def test_create_gives_up_after_max_attempts(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""При постоянной коллизии номера и slug после 5 попыток исключение всплывает."""
owner = await _make_user(db_session)
existing = Conference(number="223456789", slug="always-colliding-slug", status="active")
db_session.add(existing)
await db_session.commit()
attempt_count = 0
def _always_colliding_number() -> str:
nonlocal attempt_count
attempt_count += 1
return "223456789"
monkeypatch.setattr(conferences_module, "generate_number", _always_colliding_number)
monkeypatch.setattr(conferences_module, "generate_slug", lambda: "always-colliding-slug")
service = ConferenceService(db_session)
with pytest.raises(IntegrityError):
await service.create(owner=owner, data=ConferenceCreateIn())
assert attempt_count == conferences_module.MAX_ID_GENERATION_ATTEMPTS
async def test_resolve_returns_none_for_unknown_query(db_session: AsyncSession) -> None:
service = ConferenceService(db_session)
assert await service.resolve("nothing-like-this-exists") is None
async def test_resolve_by_number_strips_internal_spaces(db_session: AsyncSession) -> None:
conference = Conference(number=generate_number(), slug=generate_slug(), status="active")
db_session.add(conference)
await db_session.commit()
service = ConferenceService(db_session)
number = conference.number
grouped = f"{number[:3]} {number[3:6]} {number[6:]}"
resolved = await service.resolve(grouped)
assert resolved is not None
assert resolved.id == conference.id
# --- Постановка `send_invitations` + `ics_sequence` ----------
async def test_create_instant_conference_does_not_enqueue_invitations(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
mock_enqueue = MagicMock()
monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
service = ConferenceService(db_session)
await service.create(owner=owner, data=ConferenceCreateIn())
mock_enqueue.assert_not_called()
async def test_create_scheduled_conference_enqueues_invitations(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
mock_enqueue = MagicMock()
monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
service = ConferenceService(db_session)
conference, _ = await service.create(owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE))
mock_enqueue.assert_called_once_with(conference.id)
async def test_create_with_summary_recipients_override_is_persisted(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner,
data=ConferenceCreateIn(scheduled_at=FUTURE, summary_recipients="owner"),
)
assert conference.summary_recipients == "owner"
assert service.to_out(conference).summary_recipients == "owner"
async def test_update_schedule_change_increments_sequence_and_enqueues(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE, duration_minutes=30)
)
assert conference.ics_sequence == 0
mock_enqueue = MagicMock()
monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
new_time = FUTURE + timedelta(hours=1)
updated = await service.update(
conference.id, actor=owner, data=ConferenceUpdateIn(scheduled_at=new_time)
)
assert updated.ics_sequence == 1
mock_enqueue.assert_called_once_with(conference.id)
async def test_update_unrelated_field_does_not_touch_sequence(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE, password=None)
)
mock_enqueue = MagicMock()
monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
updated = await service.update(
conference.id, actor=owner, data=ConferenceUpdateIn(is_closed=True, password="secret1")
)
assert updated.ics_sequence == 0
mock_enqueue.assert_not_called()
async def test_update_summary_recipients_explicit_null_resets_override(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner,
data=ConferenceCreateIn(scheduled_at=FUTURE, summary_recipients="owner"),
)
assert conference.summary_recipients == "owner"
updated = await service.update(
conference.id, actor=owner, data=ConferenceUpdateIn(summary_recipients=None)
)
assert updated.summary_recipients is None
# --- Постановка рассылки не должна ронять запрос при недоступности брокера ------------
async def test_create_survives_enqueue_invitations_failure(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Конференция уже закоммичена — сбой постановки .ics-рассылки (Redis недоступен)
не должен ронять `create` 500-й; компенсация — ручная рассылка из админки."""
owner = await _make_user(db_session)
await db_session.commit()
monkeypatch.setattr(
conferences_module,
"enqueue_invitations",
MagicMock(side_effect=ConnectionError("брокер недоступен")),
)
service = ConferenceService(db_session)
conference, join = await service.create(
owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE)
)
assert join is None
assert conference.id is not None
reloaded = await ConferenceService(db_session).resolve(conference.number)
assert reloaded is not None
assert reloaded.id == conference.id
async def test_update_survives_enqueue_invitations_failure(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Аналогично для `update`: правка расписания сохраняется и `ics_sequence`
растёт, даже если постановку рассылки не удалось поставить в очередь."""
owner = await _make_user(db_session)
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE, duration_minutes=30)
)
monkeypatch.setattr(
conferences_module,
"enqueue_invitations",
MagicMock(side_effect=ConnectionError("брокер недоступен")),
)
new_time = FUTURE + timedelta(hours=1)
updated = await service.update(
conference.id, actor=owner, data=ConferenceUpdateIn(scheduled_at=new_time)
)
assert updated.ics_sequence == 1
assert updated.scheduled_at == new_time
# --- Участники (ADR-003) ---------------------------------------
async def test_create_participants_dedupes_owner_and_lists_organizer_first(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Организатор всегда первый в `participants` и неудаляем — передача его же
(по `user_id` и по email) в списке молча дедуплицируется (ADR-003, п.2)."""
owner = await _make_user(db_session, name="Owner Tester")
external_invitee = await _make_user(db_session, name="Invited Colleague")
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner,
data=ConferenceCreateIn(
scheduled_at=FUTURE,
participants=[
InviteeIn(user_id=owner.id), # дубль владельца — дедуп
InviteeIn(email=owner.email.upper()), # email владельца — тоже дедуп
InviteeIn(user_id=external_invitee.id),
],
),
)
out = await service.to_detail_out(conference, viewer=owner)
assert [p.user_id for p in out.participants] == [owner.id, external_invitee.id]
assert out.participants[0].is_organizer is True
assert out.participants[1].is_organizer is False
assert out.participants[1].name == "Invited Colleague"
async def test_create_with_unknown_participant_user_id_raises(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
unknown_user_id = uuid.uuid4()
service = ConferenceService(db_session)
with pytest.raises(InviteeUserNotFoundError):
await service.create(
owner=owner,
data=ConferenceCreateIn(
scheduled_at=FUTURE, participants=[InviteeIn(user_id=unknown_user_id)]
),
)
async def test_update_replaces_participant_set(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
first_invitee = await _make_user(db_session, name="First Invitee")
second_invitee = await _make_user(db_session, name="Second Invitee")
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner,
data=ConferenceCreateIn(
scheduled_at=FUTURE, participants=[InviteeIn(user_id=first_invitee.id)]
),
)
updated = await service.update(
conference.id,
actor=owner,
data=ConferenceUpdateIn(participants=[InviteeIn(user_id=second_invitee.id)]),
)
out = await service.to_detail_out(updated, viewer=owner)
participant_ids = {p.user_id for p in out.participants}
assert participant_ids == {owner.id, second_invitee.id}
assert first_invitee.id not in participant_ids
async def test_update_participants_none_does_not_change_existing_set(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`participants=None` в PATCH — «не менять» (ADR-003, п.3)."""
owner = await _make_user(db_session)
invitee = await _make_user(db_session, name="Untouched Invitee")
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner,
data=ConferenceCreateIn(scheduled_at=FUTURE, participants=[InviteeIn(user_id=invitee.id)]),
)
updated = await service.update(
conference.id, actor=owner, data=ConferenceUpdateIn(title="Renamed")
)
out = await service.to_detail_out(updated, viewer=owner)
assert invitee.id in {p.user_id for p in out.participants}
async def test_update_participants_only_change_enqueues_without_incrementing_sequence(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Изменение ТОЛЬКО состава: рассылка ставится, `ics_sequence` не растёт (анти-спам)."""
owner = await _make_user(db_session)
invitee = await _make_user(db_session, name="New Invitee")
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE, duration_minutes=30)
)
assert conference.ics_sequence == 0
mock_enqueue = MagicMock()
monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
updated = await service.update(
conference.id,
actor=owner,
data=ConferenceUpdateIn(participants=[InviteeIn(user_id=invitee.id)]),
)
assert updated.ics_sequence == 0
mock_enqueue.assert_called_once_with(conference.id)
async def test_update_same_participant_set_does_not_enqueue(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Повторная передача ТОГО ЖЕ состава — фактических изменений нет, рассылка не ставится."""
owner = await _make_user(db_session)
invitee = await _make_user(db_session, name="Same Invitee")
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner,
data=ConferenceCreateIn(scheduled_at=FUTURE, participants=[InviteeIn(user_id=invitee.id)]),
)
mock_enqueue = MagicMock()
monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
await service.update(
conference.id,
actor=owner,
data=ConferenceUpdateIn(participants=[InviteeIn(user_id=invitee.id)]),
)
mock_enqueue.assert_not_called()
async def test_list_my_does_not_populate_participants(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`/my` не раздувает состав — `participants` пуст (ADR-003, п.5)."""
owner = await _make_user(db_session)
invitee = await _make_user(db_session, name="Should Not Appear")
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner,
data=ConferenceCreateIn(is_pinned=True, participants=[InviteeIn(user_id=invitee.id)]),
)
my_list = await service.list_my(owner=owner)
item = next(c for c in my_list if c.id == conference.id)
assert item.participants == []
assert item.owner_id == owner.id
assert item.is_owner is True
assert item.organizer_name == owner.name_user