Files
vidconf/backend/tests/test_users_api.py
Max Ronzhin fec9255baa feat(backend): модуль «замена фона» и хранилище своих картинок
Отключаемый в админке модуль `virtual_background` (дефолт — выключен, чтобы
обновление не меняло продукт у тех, кто ничего не просил). Флаг едет клиенту
двумя путями: на публичные страницы входа — через `GET /public/settings`,
участнику комнаты — в join-ответе (`JoinOut`), потому что значение нужно на
руках ДО первого рендера комнаты, а `/admin/settings` доступен только админу.

Свои картинки пользователя (`/users/me/backgrounds`, GET/POST/DELETE):
файлы на диске (`backgrounds/{user_id}/{id}.{ext}`), в БД только путь — как у
аватаров, «чтобы не грузили БД». Лимит в 10 штук проверяется на сервере под
блокировкой строки пользователя: две одновременные загрузки иначе обе увидели
бы «уже девять» и обе прошли бы. Удаление сносит и запись, и файл; чужую
картинку по её id удалить нельзя — владелец в условии запроса.

Валидация загрузки (допустимые форматы, магические байты, реальный размер)
выделена из `services/avatars.py` в общий `services/images.py`: правила у
аватара и фона одни и те же, а разъехавшись, они дали бы дыру ровно там, ради
чего проверка и написана. Публичный API аватаров не изменился.

Сжимает картинку клиент (Pillow на бэкенде нет), но серверная валидация
остаётся полноценной — запрос может прийти и мимо интерфейса.

Новый ключ настройки вписан в `_MANAGED_KEYS` тестов: без этого включённый
в общей dev-БД модуль ронял чужие тесты, которые считают себя изолированными.
2026-08-10 08:59:16 +03:00

544 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.
"""Интеграционные тесты `/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
from services.backgrounds import MAX_BACKGROUNDS_PER_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
# --- Свои картинки фона (`/users/me/backgrounds`) -----------------
async def _upload_background(
client: httpx.AsyncClient, user: User, *, name: str = "bg.webp"
) -> httpx.Response:
return await client.post(
"/api/v1/users/me/backgrounds",
headers=_auth_headers(user),
files={"file": (name, _WEBP_BYTES, "image/webp")},
)
async def test_backgrounds_list_is_empty_by_default_and_reports_limit(
media_root: Path, 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/backgrounds", headers=_auth_headers(user))
assert response.status_code == 200, response.text
body = response.json()
assert body["items"] == []
assert body["limit"] == MAX_BACKGROUNDS_PER_USER
async def test_upload_background_saves_file_and_returns_url(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await _upload_background(client, user)
assert response.status_code == 201, response.text
items = response.json()["items"]
assert len(items) == 1
url = items[0]["url"]
assert url == f"/media/backgrounds/{user.id}/{items[0]['id']}.webp"
# Файл лежит на диске, в БД только путь — см. `services/backgrounds.py`.
assert (media_root / url.removeprefix("/media/")).read_bytes() == _WEBP_BYTES
async def test_upload_background_spoofed_content_type_returns_415(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""`Content-Type: image/webp`, но байты — JPEG: клиенту не верим и здесь."""
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/users/me/backgrounds",
headers=_auth_headers(user),
files={"file": ("bg.webp", _JPEG_BYTES, "image/webp")},
)
assert response.status_code == 415
assert response.json()["detail"] == "background_invalid_type"
async def test_upload_background_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 = _WEBP_BYTES + b"\x00" * (2 * 1024 * 1024)
response = await client.post(
"/api/v1/users/me/backgrounds",
headers=_auth_headers(user),
files={"file": ("bg.webp", oversized, "image/webp")},
)
assert response.status_code == 413
assert response.json()["detail"] == "background_too_large"
async def test_upload_background_over_limit_returns_409_and_keeps_ten(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Лимит держится на СЕРВЕРЕ: интерфейс можно обойти прямым запросом."""
user = await _make_user(db_session)
await db_session.commit()
for _ in range(MAX_BACKGROUNDS_PER_USER):
assert (await _upload_background(client, user)).status_code == 201
response = await _upload_background(client, user)
assert response.status_code == 409
assert response.json()["detail"] == "background_limit_reached"
listing = await client.get("/api/v1/users/me/backgrounds", headers=_auth_headers(user))
assert len(listing.json()["items"]) == MAX_BACKGROUNDS_PER_USER
async def test_delete_background_removes_file_and_row(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
created = (await _upload_background(client, user)).json()["items"][0]
file_path = media_root / created["url"].removeprefix("/media/")
assert file_path.exists()
response = await client.delete(
f"/api/v1/users/me/backgrounds/{created['id']}", headers=_auth_headers(user)
)
assert response.status_code == 204
assert not file_path.exists()
listing = await client.get("/api/v1/users/me/backgrounds", headers=_auth_headers(user))
assert listing.json()["items"] == []
async def test_delete_other_users_background_returns_404(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Знание id чужой картинки не даёт её удалить — владелец в условии запроса."""
owner = await _make_user(db_session)
stranger = await _make_user(db_session)
await db_session.commit()
created = (await _upload_background(client, owner)).json()["items"][0]
response = await client.delete(
f"/api/v1/users/me/backgrounds/{created['id']}", headers=_auth_headers(stranger)
)
assert response.status_code == 404
assert (media_root / created["url"].removeprefix("/media/")).exists()
async def test_backgrounds_list_does_not_leak_other_users_images(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
stranger = await _make_user(db_session)
await db_session.commit()
await _upload_background(client, owner)
response = await client.get("/api/v1/users/me/backgrounds", headers=_auth_headers(stranger))
assert response.json()["items"] == []
async def test_backgrounds_require_authentication(
media_root: Path, client: httpx.AsyncClient
) -> None:
assert (await client.get("/api/v1/users/me/backgrounds")).status_code == 401
assert (
await client.delete(f"/api/v1/users/me/backgrounds/{uuid.uuid4()}")
).status_code == 401