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

396 lines
14 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.
"""Интеграционные тесты `/api/v1/users`: список пользователей, профиль, аватар."""
import uuid
from collections.abc import Generator
from pathlib import Path
import httpx
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.security import create_access_token, hash_password
from models.team import Team
from models.user import User
# Минимальные валидные по магическим байтам содержимые (без полноценного
# декодирования — `services/avatars.py` проверяет только сигнатуру/размер).
_JPEG_BYTES = b"\xff\xd8\xff\xe0" + b"\x00" * 32
_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32
_WEBP_BYTES = b"RIFF\x00\x00\x00\x00WEBP" + b"\x00" * 32
async def _make_user(session: AsyncSession) -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="List 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}"}
@pytest.fixture
def media_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[Path, None, None]:
"""Подменить `MEDIA_ROOT` на изолированный `tmp_path` на время теста.
`get_settings()` кэширован (`lru_cache`) — без явного сброса кэша тест
продолжил бы видеть значение, закэшированное первым же вызовом в сессии
pytest. Перечисляется в сигнатуре теста ПЕРВЫМ параметром, чтобы pytest
инициализировал его до `client`/`app` (см. `main.py::create_app`,
монтирующий `StaticFiles` по значению `media_root` на момент вызова).
"""
monkeypatch.setenv("MEDIA_ROOT", str(tmp_path))
get_settings.cache_clear()
yield tmp_path
get_settings.cache_clear()
async def test_list_users_returns_id_and_display_name(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
requester = await _make_user(db_session)
other = await _make_user(db_session)
await db_session.commit()
response = await client.get("/api/v1/users", headers=_auth_headers(requester))
assert response.status_code == 200
body = response.json()
ids = {item["id"] for item in body}
assert str(requester.id) in ids
assert str(other.id) in ids
item = next(i for i in body if i["id"] == str(other.id))
assert item["display_name"] == other.name_user
async def test_list_users_requires_auth(client: httpx.AsyncClient) -> None:
response = await client.get("/api/v1/users")
assert response.status_code == 401
async def test_get_me_with_reserved_tld_email_does_not_500(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Регрессия: `UserOut.email` — `str`, а не `EmailStr` (иначе 500 `ResponseValidationError`).
`.local`/`.test` и т.п. — синтаксически валидные, но зарезервированные
special-use домены (RFC 6761); email-validator (`EmailStr`) их отклоняет,
хотя в БД такой email мог оказаться и до этого правила (например,
сид-админ `admin@vidconf.local` в старых окружениях).
"""
# Уникальный email (не сталкивается с реально засеянным admin@vidconf.local
# в дев-БД — тесты идут в общей БД под savepoint, а email уникален).
legacy_email = f"legacy-{uuid.uuid4()}@vidconf.local"
user = User(
email=legacy_email,
name_user="Legacy Admin",
password_hash=await hash_password("password123"),
email_verified=True,
role="admin",
)
db_session.add(user)
await db_session.commit()
response = await client.get("/api/v1/users/me", headers=_auth_headers(user))
assert response.status_code == 200, response.text
body = response.json()
assert body["email"] == legacy_email
assert body["role"] == "admin"
# --- Профиль ------------------------------------------------------
async def test_get_me_returns_profile_fields(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.get("/api/v1/users/me", headers=_auth_headers(user))
assert response.status_code == 200, response.text
body = response.json()
assert body["avatar_url"] is None
assert body["team_id"] is None
assert body["team_name"] is None
async def test_patch_me_updates_name_and_team_but_ignores_email(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
team = await _make_team(db_session)
await db_session.commit()
original_email = user.email
response = await client.patch(
"/api/v1/users/me",
json={"name_user": "Renamed Self", "team_id": str(team.id), "email": "new@example.com"},
headers=_auth_headers(user),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["name_user"] == "Renamed Self"
assert body["team_id"] == str(team.id)
assert body["team_name"] == team.name
assert body["email"] == original_email # email — read-only, тело проигнорировано
async def test_patch_me_with_unknown_team_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.patch(
"/api/v1/users/me",
json={"team_id": str(uuid.uuid4())},
headers=_auth_headers(user),
)
assert response.status_code == 404
assert response.json()["detail"] == "team_not_found"
async def test_patch_me_explicit_null_team_clears_it(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
team = await _make_team(db_session)
await db_session.commit()
await client.patch(
"/api/v1/users/me", json={"team_id": str(team.id)}, headers=_auth_headers(user)
)
response = await client.patch(
"/api/v1/users/me", json={"team_id": None}, headers=_auth_headers(user)
)
assert response.status_code == 200, response.text
assert response.json()["team_id"] is None
# --- Аватар -------------------------------------------------------
async def test_upload_avatar_jpeg_happy_path(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/users/me/avatar",
headers=_auth_headers(user),
files={"file": ("avatar.jpg", _JPEG_BYTES, "image/jpeg")},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["avatar_url"] is not None
assert body["avatar_url"].startswith(f"/media/avatars/{user.id}.jpg?v=")
assert (media_root / "avatars" / f"{user.id}.jpg").read_bytes() == _JPEG_BYTES
async def test_upload_avatar_png_happy_path(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/users/me/avatar",
headers=_auth_headers(user),
files={"file": ("avatar.png", _PNG_BYTES, "image/png")},
)
assert response.status_code == 200, response.text
assert (media_root / "avatars" / f"{user.id}.png").exists()
async def test_upload_avatar_replaces_previous_file_with_different_extension(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
await client.post(
"/api/v1/users/me/avatar",
headers=_auth_headers(user),
files={"file": ("avatar.jpg", _JPEG_BYTES, "image/jpeg")},
)
response = await client.post(
"/api/v1/users/me/avatar",
headers=_auth_headers(user),
files={"file": ("avatar.webp", _WEBP_BYTES, "image/webp")},
)
assert response.status_code == 200, response.text
assert not (media_root / "avatars" / f"{user.id}.jpg").exists()
assert (media_root / "avatars" / f"{user.id}.webp").exists()
async def test_upload_avatar_spoofed_content_type_returns_415(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""`Content-Type: image/png`, но байты — JPEG (подделка заголовка клиентом)."""
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/users/me/avatar",
headers=_auth_headers(user),
files={"file": ("avatar.png", _JPEG_BYTES, "image/png")},
)
assert response.status_code == 415
assert response.json()["detail"] == "avatar_invalid_type"
async def test_upload_avatar_unsupported_content_type_returns_415(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/users/me/avatar",
headers=_auth_headers(user),
files={"file": ("avatar.txt", b"not-an-image", "text/plain")},
)
assert response.status_code == 415
async def test_upload_avatar_too_large_returns_413(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
oversized = _JPEG_BYTES + b"\x00" * (2 * 1024 * 1024)
response = await client.post(
"/api/v1/users/me/avatar",
headers=_auth_headers(user),
files={"file": ("avatar.jpg", oversized, "image/jpeg")},
)
assert response.status_code == 413
assert response.json()["detail"] == "avatar_too_large"
async def test_delete_avatar_removes_file_and_clears_url(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
await client.post(
"/api/v1/users/me/avatar",
headers=_auth_headers(user),
files={"file": ("avatar.jpg", _JPEG_BYTES, "image/jpeg")},
)
response = await client.delete("/api/v1/users/me/avatar", headers=_auth_headers(user))
assert response.status_code == 204
assert not (media_root / "avatars" / f"{user.id}.jpg").exists()
me = await client.get("/api/v1/users/me", headers=_auth_headers(user))
assert me.json()["avatar_url"] is None
# --- Смена пароля ---------------------------------------------------------------------
async def test_change_password_success_new_password_works_old_does_not(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
email = user.email
await db_session.commit()
response = await client.post(
"/api/v1/users/me/password",
json={"current_password": "password123", "new_password": "new-password456"},
headers=_auth_headers(user),
)
assert response.status_code == 204, response.text
new_login = await client.post(
"/api/v1/auth/token", data={"username": email, "password": "new-password456"}
)
assert new_login.status_code == 200, new_login.text
old_login = await client.post(
"/api/v1/auth/token", data={"username": email, "password": "password123"}
)
assert old_login.status_code == 401
async def test_change_password_wrong_current_returns_400(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/users/me/password",
json={"current_password": "wrong-password", "new_password": "new-password456"},
headers=_auth_headers(user),
)
assert response.status_code == 400
assert response.json()["detail"] == "invalid_current_password"
async def test_change_password_too_short_returns_422(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/users/me/password",
json={"current_password": "password123", "new_password": "short"},
headers=_auth_headers(user),
)
assert response.status_code == 422
async def test_change_password_requires_auth(client: httpx.AsyncClient) -> None:
response = await client.post(
"/api/v1/users/me/password",
json={"current_password": "a", "new_password": "new-password456"},
)
assert response.status_code == 401
# --- Пикер участников: поиск `q` + `avatar_url` -------------------
async def test_list_users_search_by_q_filters_by_name_or_email(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
requester = await _make_user(db_session)
unique_marker = str(uuid.uuid4())
match = User(
email=f"{unique_marker}@example.com",
name_user=f"Findable {unique_marker}",
password_hash=await hash_password("password123"),
email_verified=True,
)
db_session.add(match)
await db_session.commit()
response = await client.get(
"/api/v1/users", params={"q": unique_marker}, headers=_auth_headers(requester)
)
assert response.status_code == 200, response.text
ids = {item["id"] for item in response.json()}
assert str(match.id) in ids
assert str(requester.id) not in ids