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

535 lines
20 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.
"""Тесты WS-чата конференции (`WS /api/v1/conferences/{id}/chat`).
Интеграционные тесты используют `ws_client` — in-process ASGI websocket
клиент (см. `tests/conftest.py::ASGIWebSocketSession`), т.к. ни
`httpx.AsyncClient`, ни `starlette.testclient.TestClient` не подходят для
websocket-тестов поверх нашей savepoint-сессии БД (см. докстринг класса).
"""
import uuid
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
import httpx
import jwt
import pytest
from pydantic import ValidationError
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from core.redis import redis_client
from core.security import create_access_token, hash_password
from models.chat import ChatMessage
from models.conference import Conference
from models.guest import GuestAccess
from models.session import ConferenceSession
from models.user import User
from repositories.chat import ChatMessageRepository
from repositories.conferences import ConferenceSessionRepository
from schemas.chat import ChatMessageIn, ChatMessageOut
from services.chat import ChatService, chat_channel
from services.conference_ids import generate_number, generate_slug
from services.instance_settings import InstanceSettingsService, SettingsUpdateIn
from services.livekit_tokens import create_room_access_token
from tests.conftest import ASGIWebSocketSession
WSFactory = Callable[[str], ASGIWebSocketSession]
# --- Хелперы ---------------------------------------------------------------
async def _make_user(session: AsyncSession, *, name: str = "Chat 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 _make_conference(session: AsyncSession, *, status: str = "active") -> Conference:
conference = Conference(
number=generate_number(), slug=generate_slug(), title="Chat Test", status=status
)
session.add(conference)
await session.flush()
return conference
async def _make_guest(session: AsyncSession, conference: Conference, *, name: str) -> GuestAccess:
guest = GuestAccess(conference_id=conference.id, display_name=name)
session.add(guest)
await session.flush()
return guest
def _chat_path(conference_id: uuid.UUID) -> str:
return f"/api/v1/conferences/{conference_id}/chat"
def _user_token(conference: Conference, user: User) -> str:
return create_room_access_token(
room_name=conference.slug, identity=str(user.id), name=user.name_user
)
def _guest_token(conference: Conference, guest: GuestAccess) -> str:
return create_room_access_token(
room_name=conference.slug, identity=f"guest:{guest.id}", name=guest.display_name
)
async def _connect_and_auth(session: ASGIWebSocketSession, token: str) -> dict[str, Any]:
"""Подключиться, аутентифицироваться и вернуть первое сообщение (`history`).
После `history` сервер сразу шлёт снапшот очереди поднятых рук
(`{"type":"hand_queue",...}`, задача B1) — здесь он молча вычитывается
и отбрасывается, чтобы не путать существующие тесты чата, которым он
не интересен (см. `tests/test_hand_queue_ws.py` для тестов самой очереди).
"""
accept = await session.connect()
assert accept["type"] == "websocket.accept"
await session.send_json({"type": "auth", "token": token})
history = await session.receive_json()
await session.receive_json()
return history
# --- Основной сценарий: обмен сообщениями + история -------------------------
async def test_two_clients_exchange_messages_and_history_on_reconnect(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
conference = await _make_conference(db_session)
user1 = await _make_user(db_session, name="Alice")
user2 = await _make_user(db_session, name="Bob")
await db_session.commit()
token1 = _user_token(conference, user1)
token2 = _user_token(conference, user2)
path = _chat_path(conference.id)
ws1 = ws_client(path)
history1 = await _connect_and_auth(ws1, token1)
assert history1 == {"type": "history", "messages": []}
ws2 = ws_client(path)
history2 = await _connect_and_auth(ws2, token2)
assert history2 == {"type": "history", "messages": []}
await ws1.send_json({"type": "message", "text": "hello from alice"})
echo = await ws1.receive_json()
assert echo["type"] == "message"
assert echo["message"]["text"] == "hello from alice"
assert echo["message"]["author_name"] == "Alice"
assert echo["message"]["is_guest"] is False
assert echo["message"]["author_id"] == str(user1.id)
assert echo["message"]["created_at"].endswith("Z")
broadcast = await ws2.receive_json()
assert broadcast == echo
await ws1.aclose()
await ws2.aclose()
ws3 = ws_client(path)
history3 = await _connect_and_auth(ws3, _user_token(conference, user1))
assert len(history3["messages"]) == 1
assert history3["messages"][0]["text"] == "hello from alice"
# --- Гонка history vs pubsub subscribe --------------------------------------
async def test_subscribe_happens_before_history_query(
db_session: AsyncSession, ws_client: WSFactory, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Regression: подписка на pub/sub должна происходить ДО SELECT истории.
Иначе сообщение, опубликованное другим клиентом в этом окне, теряется
для подключающегося клиента.
"""
conference = await _make_conference(db_session)
user = await _make_user(db_session)
await db_session.commit()
call_order: list[str] = []
original_history = ChatService.history
async def _tracked_history(self: ChatService, conference: Conference) -> list[ChatMessageOut]:
call_order.append("history")
return await original_history(self, conference)
monkeypatch.setattr(ChatService, "history", _tracked_history)
import redis.asyncio.client as redis_client_module
original_subscribe = redis_client_module.PubSub.subscribe
async def _tracked_subscribe(self: Any, *args: Any, **kwargs: Any) -> Any:
call_order.append("subscribe")
return await original_subscribe(self, *args, **kwargs)
monkeypatch.setattr(redis_client_module.PubSub, "subscribe", _tracked_subscribe)
token = _user_token(conference, user)
ws = ws_client(_chat_path(conference.id))
await _connect_and_auth(ws, token)
assert call_order == ["subscribe", "history"]
async def test_no_duplicate_when_message_already_in_history(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Сообщение, уже попавшее в history, не должно продублироваться через pub/sub.
Имитирует стык гонки: то же сообщение (тот же `id`) публикуется в канал
уже ПОСЛЕ того, как клиент получил его в `history` — дедуп по `id`
(`_pump_pubsub_to_websocket`) должен его отфильтровать.
"""
conference = await _make_conference(db_session)
user = await _make_user(db_session, name="Eve")
await db_session.commit()
session_record = await ConferenceSessionRepository(db_session).create(
conference_id=conference.id, title=conference.title, t_start=datetime.now(UTC)
)
existing = await ChatMessageRepository(db_session).add(
session_id=session_record.id,
user_id=user.id,
guest_access_id=None,
author_name="Eve",
text="already there",
)
await db_session.commit()
token = _user_token(conference, user)
ws = ws_client(_chat_path(conference.id))
history = await _connect_and_auth(ws, token)
assert len(history["messages"]) == 1
assert history["messages"][0]["id"] == existing.id
duplicate_payload = ChatMessageOut(
id=existing.id,
author_id=str(user.id),
author_name="Eve",
is_guest=False,
text="already there",
created_at=existing.created_at,
)
await redis_client.publish(chat_channel(conference.id), duplicate_payload.model_dump_json())
# Настоящее новое сообщение отправляем следом — оно должно дойти БЕЗ
# опережающего дубликата (если бы дедуп не работал, первым пришёл бы
# повтор "already there").
await ws.send_json({"type": "message", "text": "genuinely new"})
received = await ws.receive_json()
assert received["type"] == "message"
assert received["message"]["text"] == "genuinely new"
# --- Auth: коды закрытия ----------------------------------------------------
async def test_no_auth_message_closes_4401(db_session: AsyncSession, ws_client: WSFactory) -> None:
conference = await _make_conference(db_session)
await db_session.commit()
ws = ws_client(_chat_path(conference.id))
accept = await ws.connect()
assert accept["type"] == "websocket.accept"
# Первым сообщением шлём НЕ auth (нарушение протокола) — трактуется как отсутствие auth.
await ws.send_json({"type": "message", "text": "too early"})
code = await ws.receive_close()
assert code == 4401
async def test_garbage_token_closes_4401(db_session: AsyncSession, ws_client: WSFactory) -> None:
conference = await _make_conference(db_session)
await db_session.commit()
ws = ws_client(_chat_path(conference.id))
await ws.connect()
await ws.send_json({"type": "auth", "token": "not-a-jwt-at-all"})
code = await ws.receive_close()
assert code == 4401
async def test_non_livekit_jwt_closes_4401(db_session: AsyncSession, ws_client: WSFactory) -> None:
conference = await _make_conference(db_session)
await db_session.commit()
# Синтаксически валидный JWT, но подписан не тем секретом (не LiveKit).
foreign_token = jwt.encode(
{"sub": "someone"}, "wrong-secret-not-livekit-at-all-32-bytes", algorithm="HS256"
)
ws = ws_client(_chat_path(conference.id))
await ws.connect()
await ws.send_json({"type": "auth", "token": foreign_token})
code = await ws.receive_close()
assert code == 4401
async def test_wrong_room_token_closes_4403(db_session: AsyncSession, ws_client: WSFactory) -> None:
conference_a = await _make_conference(db_session)
conference_b = await _make_conference(db_session)
user = await _make_user(db_session)
await db_session.commit()
# Токен выдан для конференции A, подключаемся к B.
token_for_a = _user_token(conference_a, user)
ws = ws_client(_chat_path(conference_b.id))
await ws.connect()
await ws.send_json({"type": "auth", "token": token_for_a})
code = await ws.receive_close()
assert code == 4403
async def test_chat_disabled_closes_4404_without_restart(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
conference = await _make_conference(db_session)
user = await _make_user(db_session)
await db_session.commit()
token = _user_token(conference, user)
path = _chat_path(conference.id)
# До выключения тоггла подключение работает штатно.
ws1 = ws_client(path)
history = await _connect_and_auth(ws1, token)
assert history["type"] == "history"
await ws1.aclose()
await InstanceSettingsService(db_session).update(SettingsUpdateIn(chat_enabled=False))
# Тоггл действует немедленно, без рестарта backend — следующее подключение отклоняется.
ws2 = ws_client(path)
await ws2.connect()
await ws2.send_json({"type": "auth", "token": token})
code = await ws2.receive_close()
assert code == 4404
async def test_conference_not_found_closes_4404(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
conference = await _make_conference(db_session)
user = await _make_user(db_session)
await db_session.commit()
# Токен валиден (комната существует), но подключаемся по чужому/несуществующему id.
token = _user_token(conference, user)
ws = ws_client(_chat_path(uuid.uuid4()))
await ws.connect()
await ws.send_json({"type": "auth", "token": token})
code = await ws.receive_close()
assert code == 4404
# --- Сообщение после room_finished ------------------------------------------
async def test_message_after_conference_ended_rejected_without_phantom_session(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""LiveKit-токен (TTL 6 часов) может пережить конференцию — сообщение отклоняется.
Сценарий: клиент подключился, пока конференция была активна; сессия
пайплайна закрывается (`t_end` проставлен, как это делает webhook
`room_finished`), а статус конференции переводится в `ended` СЫРЫМ SQL
(в обход ORM identity map — имитация того, что вебхук работает в ДРУГОЙ
сессии/процессе и `expire_on_commit=False` не даёт уже загруженному
объекту `conference` увидеть новый статус). Отправленное после этого
сообщение должно быть отклонено (close 4404), новая "фантомная" открытая
сессия — НЕ создана, сообщение — НЕ сохранено.
"""
conference = await _make_conference(db_session, status="active")
user = await _make_user(db_session)
await db_session.commit()
token = _user_token(conference, user)
ws = ws_client(_chat_path(conference.id))
await _connect_and_auth(ws, token)
sessions_repo = ConferenceSessionRepository(db_session)
session_record = await sessions_repo.get_open_by_conference(conference.id)
if session_record is None:
session_record = await sessions_repo.create(
conference_id=conference.id, title=conference.title, t_start=datetime.now(UTC)
)
await sessions_repo.close(session_record, t_end=datetime.now(UTC))
# Сырой UPDATE — намеренно в обход ORM, чтобы не обновить закэшированный
# в текущей сессии Python-объект `conference` (имитация другой сессии/процесса).
await db_session.execute(
text("UPDATE conferences SET status = 'ended' WHERE id = :id"), {"id": conference.id}
)
await db_session.commit()
await ws.send_json({"type": "message", "text": "too late"})
code = await ws.receive_close()
assert code == 4404
sessions = (
(
await db_session.execute(
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
)
)
.scalars()
.all()
)
assert len(sessions) == 1
assert sessions[0].id == session_record.id
assert sessions[0].t_end is not None
stray = (
await db_session.execute(select(ChatMessage).where(ChatMessage.text == "too late"))
).scalar_one_or_none()
assert stray is None
async def test_message_before_room_started_creates_session_for_active_conference(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Легитимный случай: первое сообщение до webhook `room_started`.
Открытой сессии ещё нет, но конференция активна (не `ended`) — создание
новой сессии допустимо (в отличие от случая, когда конференция уже
завершена).
"""
conference = await _make_conference(db_session, status="active")
user = await _make_user(db_session)
await db_session.commit()
token = _user_token(conference, user)
ws = ws_client(_chat_path(conference.id))
await _connect_and_auth(ws, token)
await ws.send_json({"type": "message", "text": "before room_started"})
echo = await ws.receive_json()
assert echo["message"]["text"] == "before room_started"
sessions = (
(
await db_session.execute(
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
)
)
.scalars()
.all()
)
assert len(sessions) == 1
assert sessions[0].t_end is None
# --- Гость -------------------------------------------------------------------
async def test_guest_message_persists_guest_access_id_and_author_name(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
conference = await _make_conference(db_session)
guest = await _make_guest(db_session, conference, name="Guest Carol")
await db_session.commit()
token = _guest_token(conference, guest)
ws = ws_client(_chat_path(conference.id))
await _connect_and_auth(ws, token)
await ws.send_json({"type": "message", "text": "hi from guest"})
echo = await ws.receive_json()
assert echo["message"]["author_name"] == "Guest Carol"
assert echo["message"]["is_guest"] is True
assert echo["message"]["author_id"] == str(guest.id)
await ws.aclose()
row = (
await db_session.execute(select(ChatMessage).where(ChatMessage.text == "hi from guest"))
).scalar_one()
assert row.guest_access_id == guest.id
assert row.user_id is None
assert row.author_name == "Guest Carol"
# --- REST: JoinOut.chat_enabled ----------------------------------------------
async def test_join_out_reflects_chat_enabled_toggle(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
headers = {"Authorization": f"Bearer {create_access_token(user.id, user.role)}"}
response = await client.post("/api/v1/conferences", json={"title": "Standup"}, headers=headers)
assert response.status_code == 201, response.text
assert response.json()["join"]["chat_enabled"] is True
await InstanceSettingsService(db_session).update(SettingsUpdateIn(chat_enabled=False))
response = await client.post(
"/api/v1/conferences", json={"title": "Standup 2"}, headers=headers
)
assert response.status_code == 201, response.text
assert response.json()["join"]["chat_enabled"] is False
async def test_guest_join_out_reflects_chat_enabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session)
await db_session.commit()
await InstanceSettingsService(db_session).update(SettingsUpdateIn(chat_enabled=False))
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Dave"}
)
assert response.status_code == 200, response.text
assert response.json()["chat_enabled"] is False
# --- Юнит: схема протокола ----------------------------------------------------
def test_chat_message_in_rejects_empty_text_after_strip() -> None:
with pytest.raises(ValidationError):
ChatMessageIn(type="message", text=" ")
def test_chat_message_in_rejects_too_long_text() -> None:
with pytest.raises(ValidationError):
ChatMessageIn(type="message", text="a" * 2001)
def test_chat_message_in_strips_surrounding_whitespace() -> None:
parsed = ChatMessageIn(type="message", text=" hello ")
assert parsed.text == "hello"
def test_chat_message_out_serializes_created_at_as_utc_z() -> None:
out = ChatMessageOut(
id=1,
author_id="abc",
author_name="Alice",
is_guest=False,
text="hi",
created_at=datetime(2026, 7, 18, 12, 0, 0, tzinfo=UTC),
)
dumped = out.model_dump(mode="json")
assert dumped["created_at"] == "2026-07-18T12:00:00Z"