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

246 lines
8.8 KiB
Python
Raw Permalink 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.
"""Тесты `POST /api/v1/conferences/{id}/mute-participant` (задача B2).
`mute_participant_track` (реальный вызов LiveKit `RoomServiceClient`) мокается
на уровне `services.conferences` — тот же паттерн, что и `start_track_egress`
в `tests/test_livekit_webhook.py`: сетевой вызов к LiveKit в тестах не нужен,
важна только бизнес-логика (права, маршрутизация ошибок, broadcast).
"""
import asyncio
import uuid
from typing import Any
from unittest.mock import AsyncMock
import httpx
import pytest
from redis.asyncio.client import PubSub
from sqlalchemy.ext.asyncio import AsyncSession
import services.conferences as conferences_module
from core.redis import redis_client
from core.security import create_access_token, hash_password
from models.conference import Conference
from models.user import User
from services.conference_ids import generate_number, generate_slug
from services.hand_queue import hand_queue_channel
from services.room_control import ParticipantNotInRoomError
MUTE_URL = "{base}/mute-participant"
async def _receive_within(pubsub: PubSub, *, max_wait: float) -> dict[str, Any] | None:
"""Дождаться СОДЕРЖАТЕЛЬНОГО сообщения канала в пределах `max_wait` секунд.
`ignore_subscribe_messages=True` у `get_message` фильтрует служебное
подтверждение подписки, но при этом всё равно может вернуть `None` для
ЭТОГО конкретного вызова (см. `api/chat.py::_pump_pubsub_to_websocket`,
ровно поэтому там `while True: ... if raw is None: continue`) — здесь тот
же цикл, но с общим дедлайном вместо бесконечного ожидания.
"""
deadline = asyncio.get_event_loop().time() + max_wait
while True:
remaining = deadline - asyncio.get_event_loop().time()
if remaining <= 0:
return None
raw: dict[str, Any] | None = await pubsub.get_message(
ignore_subscribe_messages=True, timeout=remaining
)
if raw is not None:
return raw
async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Mute Tester",
password_hash=await hash_password("password123"),
email_verified=True,
role=role,
)
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(),
title="Mute Test",
status="active",
owner_id=owner_id,
)
session.add(conference)
await session.flush()
return conference
def _auth_headers(user: User) -> dict[str, str]:
return {"Authorization": f"Bearer {create_access_token(user.id, user.role)}"}
def _url(conference_id: uuid.UUID) -> str:
return MUTE_URL.format(base=f"/api/v1/conferences/{conference_id}")
async def test_owner_can_mute_participant_and_broadcast_is_published(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
mock_mute = AsyncMock(return_value=True)
monkeypatch.setattr(conferences_module, "mute_participant_track", mock_mute)
pubsub = redis_client.pubsub()
channel = hand_queue_channel(conference.id)
await pubsub.subscribe(channel)
try:
response = await client.post(
_url(conference.id),
json={"identity": "some-identity", "source": "microphone"},
headers=_auth_headers(owner),
)
assert response.status_code == 200, response.text
assert response.json() == {"muted": True}
mock_mute.assert_awaited_once_with(
conference.slug, identity="some-identity", source="microphone"
)
raw = await _receive_within(pubsub, max_wait=2)
assert raw is not None
assert raw["data"] == (
'{"type":"forced_mute","identity":"some-identity","source":"microphone"}'
)
finally:
await pubsub.unsubscribe(channel)
await pubsub.aclose() # type: ignore[no-untyped-call]
async def test_mute_already_off_does_not_broadcast(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Трек не был опубликован (камера/мьют и так выключены, задача A1) — не ошибка.
Ответ `muted=false` (искомое состояние уже достигнуто), без broadcast'а.
"""
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
monkeypatch.setattr(
conferences_module, "mute_participant_track", AsyncMock(return_value=False)
)
pubsub = redis_client.pubsub()
channel = hand_queue_channel(conference.id)
await pubsub.subscribe(channel)
try:
response = await client.post(
_url(conference.id),
json={"identity": "some-identity", "source": "camera"},
headers=_auth_headers(owner),
)
assert response.status_code == 200, response.text
assert response.json() == {"muted": False}
raw = await _receive_within(pubsub, max_wait=0.5)
assert raw is None
finally:
await pubsub.unsubscribe(channel)
await pubsub.aclose() # type: ignore[no-untyped-call]
async def test_admin_can_mute_participant_of_someone_elses_conference(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
admin = await _make_user(db_session, role="admin")
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
monkeypatch.setattr(conferences_module, "mute_participant_track", AsyncMock(return_value=True))
response = await client.post(
_url(conference.id),
json={"identity": "some-identity", "source": "microphone"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
async def test_regular_participant_cannot_mute_someone_else(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
other = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
mock_mute = AsyncMock()
monkeypatch.setattr(conferences_module, "mute_participant_track", mock_mute)
response = await client.post(
_url(conference.id),
json={"identity": str(owner.id), "source": "microphone"},
headers=_auth_headers(other),
)
assert response.status_code == 403
assert response.json()["detail"] == "not_owner"
mock_mute.assert_not_awaited()
async def test_mute_conference_not_found(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
_url(uuid.uuid4()),
json={"identity": "some-identity", "source": "microphone"},
headers=_auth_headers(user),
)
assert response.status_code == 404
assert response.json()["detail"] == "conference_not_found"
async def test_mute_participant_not_in_room_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
monkeypatch.setattr(
conferences_module,
"mute_participant_track",
AsyncMock(side_effect=ParticipantNotInRoomError()),
)
response = await client.post(
_url(conference.id),
json={"identity": "ghost", "source": "microphone"},
headers=_auth_headers(owner),
)
assert response.status_code == 404
assert response.json()["detail"] == "participant_not_in_room"
async def test_mute_rejects_invalid_source(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
response = await client.post(
_url(conference.id),
json={"identity": "some-identity", "source": "screen_share"},
headers=_auth_headers(owner),
)
assert response.status_code == 422