На нагрузочном тесте 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 параллельных проверок укладываются заметно быстрее восьми последовательных; хэш со старыми параметрами принимается и перевыпускается при входе.
300 lines
10 KiB
Python
300 lines
10 KiB
Python
"""Интеграционные тесты справочника команд `/api/v1/admin/teams` и привязки `users.team_id`.
|
||
|
||
Покрывает: CRUD команд, 409 на дубль имени, 403 не-админу на новые
|
||
эндпоинты, назначение/снятие команды пользователю (в т.ч. явный `null`),
|
||
404 на несуществующую команду, обнуление `team_id` при удалении команды,
|
||
разрешённое самоизменение `team_id` (в отличие от `role`/`is_blocked`).
|
||
"""
|
||
|
||
import uuid
|
||
|
||
import httpx
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from core.security import create_access_token, hash_password
|
||
from models.team import Team
|
||
from models.user import User
|
||
|
||
|
||
async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
|
||
user = User(
|
||
email=f"{uuid.uuid4()}@example.com",
|
||
name_user="Team API Tester",
|
||
password_hash=await hash_password("password123"),
|
||
email_verified=True,
|
||
role=role,
|
||
)
|
||
session.add(user)
|
||
await session.flush()
|
||
return user
|
||
|
||
|
||
async def _make_team(session: AsyncSession, *, name: str | None = None) -> Team:
|
||
team = Team(name=name or 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}"}
|
||
|
||
|
||
# --- 403 не-админу -------------------------------------------------------------------
|
||
|
||
|
||
async def test_team_endpoints_forbidden_for_non_admin(
|
||
client: httpx.AsyncClient, db_session: AsyncSession
|
||
) -> None:
|
||
user = await _make_user(db_session)
|
||
team = await _make_team(db_session)
|
||
await db_session.commit()
|
||
headers = _auth_headers(user)
|
||
|
||
requests = [
|
||
("GET", "/api/v1/admin/teams", None),
|
||
("POST", "/api/v1/admin/teams", {"name": "X"}),
|
||
("PATCH", f"/api/v1/admin/teams/{team.id}", {"name": "Y"}),
|
||
("DELETE", f"/api/v1/admin/teams/{team.id}", None),
|
||
]
|
||
for method, path, body in requests:
|
||
response = await client.request(method, path, json=body, headers=headers)
|
||
assert response.status_code == 403, f"{method} {path} -> {response.status_code}"
|
||
|
||
|
||
# --- CRUD команд ---------------------------------------------------------------------
|
||
|
||
|
||
async def test_create_and_list_teams_sorted_by_name(
|
||
client: httpx.AsyncClient, db_session: AsyncSession
|
||
) -> None:
|
||
"""Список отсортирован по названию; сравниваем взаимный порядок и наличие
|
||
своих (уникальных по суффиксу) команд, а не полный список — в общей
|
||
dev-БД могут быть и другие, легитимные команды разработчика."""
|
||
admin = await _make_user(db_session, role="admin")
|
||
suffix = uuid.uuid4().hex[:8]
|
||
name_alpha, name_beta, name_zeta = f"Alpha-{suffix}", f"Beta-{suffix}", f"Zeta-{suffix}"
|
||
await _make_team(db_session, name=name_zeta)
|
||
await _make_team(db_session, name=name_alpha)
|
||
await db_session.commit()
|
||
|
||
response = await client.post(
|
||
"/api/v1/admin/teams", json={"name": name_beta}, headers=_auth_headers(admin)
|
||
)
|
||
assert response.status_code == 201, response.text
|
||
created = response.json()
|
||
assert created["name"] == name_beta
|
||
assert "id" in created and "created_at" in created
|
||
|
||
list_response = await client.get("/api/v1/admin/teams", headers=_auth_headers(admin))
|
||
assert list_response.status_code == 200
|
||
body = list_response.json()
|
||
names = [item["name"] for item in body["items"]]
|
||
assert names == sorted(names)
|
||
assert name_alpha in names and name_beta in names and name_zeta in names
|
||
assert names.index(name_alpha) < names.index(name_beta) < names.index(name_zeta)
|
||
assert body["total"] == len(body["items"])
|
||
|
||
|
||
async def test_create_team_strips_name(client: httpx.AsyncClient, db_session: AsyncSession) -> None:
|
||
admin = await _make_user(db_session, role="admin")
|
||
await db_session.commit()
|
||
|
||
response = await client.post(
|
||
"/api/v1/admin/teams", json={"name": " Padded "}, headers=_auth_headers(admin)
|
||
)
|
||
assert response.status_code == 201, response.text
|
||
assert response.json()["name"] == "Padded"
|
||
|
||
|
||
async def test_create_team_duplicate_name_returns_409(
|
||
client: httpx.AsyncClient, db_session: AsyncSession
|
||
) -> None:
|
||
admin = await _make_user(db_session, role="admin")
|
||
name = f"Duplicate {uuid.uuid4()}"
|
||
await _make_team(db_session, name=name)
|
||
await db_session.commit()
|
||
|
||
response = await client.post(
|
||
"/api/v1/admin/teams", json={"name": name}, headers=_auth_headers(admin)
|
||
)
|
||
assert response.status_code == 409
|
||
|
||
|
||
async def test_update_team_renames(client: httpx.AsyncClient, db_session: AsyncSession) -> None:
|
||
admin = await _make_user(db_session, role="admin")
|
||
team = await _make_team(db_session, name=f"Old Name {uuid.uuid4()}")
|
||
await db_session.commit()
|
||
|
||
new_name = f"New Name {uuid.uuid4()}"
|
||
response = await client.patch(
|
||
f"/api/v1/admin/teams/{team.id}", json={"name": new_name}, headers=_auth_headers(admin)
|
||
)
|
||
assert response.status_code == 200, response.text
|
||
assert response.json()["name"] == new_name
|
||
|
||
|
||
async def test_update_team_duplicate_name_returns_409(
|
||
client: httpx.AsyncClient, db_session: AsyncSession
|
||
) -> None:
|
||
admin = await _make_user(db_session, role="admin")
|
||
name_a = f"Team A {uuid.uuid4()}"
|
||
team_a = await _make_team(db_session, name=name_a)
|
||
team_b = await _make_team(db_session, name=f"Team B {uuid.uuid4()}")
|
||
await db_session.commit()
|
||
|
||
response = await client.patch(
|
||
f"/api/v1/admin/teams/{team_b.id}",
|
||
json={"name": name_a},
|
||
headers=_auth_headers(admin),
|
||
)
|
||
assert response.status_code == 409
|
||
assert team_a.name == name_a
|
||
|
||
|
||
async def test_update_team_unknown_returns_404(
|
||
client: httpx.AsyncClient, db_session: AsyncSession
|
||
) -> None:
|
||
admin = await _make_user(db_session, role="admin")
|
||
await db_session.commit()
|
||
|
||
response = await client.patch(
|
||
f"/api/v1/admin/teams/{uuid.uuid4()}", json={"name": "X"}, headers=_auth_headers(admin)
|
||
)
|
||
assert response.status_code == 404
|
||
|
||
|
||
async def test_delete_team_unknown_returns_404(
|
||
client: httpx.AsyncClient, db_session: AsyncSession
|
||
) -> None:
|
||
admin = await _make_user(db_session, role="admin")
|
||
await db_session.commit()
|
||
|
||
response = await client.delete(
|
||
f"/api/v1/admin/teams/{uuid.uuid4()}", headers=_auth_headers(admin)
|
||
)
|
||
assert response.status_code == 404
|
||
|
||
|
||
async def test_delete_team_clears_user_team_id(
|
||
client: httpx.AsyncClient, db_session: AsyncSession
|
||
) -> None:
|
||
admin = await _make_user(db_session, role="admin")
|
||
team = await _make_team(db_session)
|
||
member = await _make_user(db_session)
|
||
member.team_id = team.id
|
||
await db_session.commit()
|
||
|
||
response = await client.delete(f"/api/v1/admin/teams/{team.id}", headers=_auth_headers(admin))
|
||
assert response.status_code == 204
|
||
|
||
await db_session.refresh(member)
|
||
assert member.team_id is None
|
||
|
||
|
||
# --- Привязка пользователя к команде ---------------------------------------------------
|
||
|
||
|
||
async def test_patch_user_assigns_team(client: httpx.AsyncClient, db_session: AsyncSession) -> None:
|
||
admin = await _make_user(db_session, role="admin")
|
||
target = await _make_user(db_session)
|
||
team = await _make_team(db_session)
|
||
await db_session.commit()
|
||
|
||
response = await client.patch(
|
||
f"/api/v1/admin/users/{target.id}",
|
||
json={"team_id": str(team.id)},
|
||
headers=_auth_headers(admin),
|
||
)
|
||
assert response.status_code == 200, response.text
|
||
assert response.json()["team_id"] == str(team.id)
|
||
|
||
|
||
async def test_patch_user_clears_team_with_explicit_null(
|
||
client: httpx.AsyncClient, db_session: AsyncSession
|
||
) -> None:
|
||
admin = await _make_user(db_session, role="admin")
|
||
team = await _make_team(db_session)
|
||
target = await _make_user(db_session)
|
||
target.team_id = team.id
|
||
await db_session.commit()
|
||
|
||
response = await client.patch(
|
||
f"/api/v1/admin/users/{target.id}",
|
||
json={"team_id": None},
|
||
headers=_auth_headers(admin),
|
||
)
|
||
assert response.status_code == 200, response.text
|
||
assert response.json()["team_id"] is None
|
||
|
||
|
||
async def test_patch_user_missing_team_field_keeps_current_team(
|
||
client: httpx.AsyncClient, db_session: AsyncSession
|
||
) -> None:
|
||
admin = await _make_user(db_session, role="admin")
|
||
team = await _make_team(db_session)
|
||
target = await _make_user(db_session)
|
||
target.team_id = team.id
|
||
await db_session.commit()
|
||
|
||
response = await client.patch(
|
||
f"/api/v1/admin/users/{target.id}",
|
||
json={"role": "admin"},
|
||
headers=_auth_headers(admin),
|
||
)
|
||
assert response.status_code == 200, response.text
|
||
assert response.json()["team_id"] == str(team.id)
|
||
|
||
|
||
async def test_patch_user_unknown_team_returns_404(
|
||
client: httpx.AsyncClient, db_session: AsyncSession
|
||
) -> None:
|
||
admin = await _make_user(db_session, role="admin")
|
||
target = await _make_user(db_session)
|
||
await db_session.commit()
|
||
|
||
response = await client.patch(
|
||
f"/api/v1/admin/users/{target.id}",
|
||
json={"team_id": str(uuid.uuid4())},
|
||
headers=_auth_headers(admin),
|
||
)
|
||
assert response.status_code == 404
|
||
|
||
|
||
async def test_admin_can_change_own_team_id(
|
||
client: httpx.AsyncClient, db_session: AsyncSession
|
||
) -> None:
|
||
admin = await _make_user(db_session, role="admin")
|
||
team = await _make_team(db_session)
|
||
await db_session.commit()
|
||
|
||
response = await client.patch(
|
||
f"/api/v1/admin/users/{admin.id}",
|
||
json={"team_id": str(team.id)},
|
||
headers=_auth_headers(admin),
|
||
)
|
||
assert response.status_code == 200, response.text
|
||
assert response.json()["team_id"] == str(team.id)
|
||
|
||
|
||
async def test_admin_still_forbidden_to_change_own_role_and_block(
|
||
client: httpx.AsyncClient, db_session: AsyncSession
|
||
) -> None:
|
||
admin = await _make_user(db_session, role="admin")
|
||
await db_session.commit()
|
||
|
||
response = await client.patch(
|
||
f"/api/v1/admin/users/{admin.id}",
|
||
json={"is_blocked": True},
|
||
headers=_auth_headers(admin),
|
||
)
|
||
assert response.status_code == 409
|
||
|
||
response = await client.patch(
|
||
f"/api/v1/admin/users/{admin.id}",
|
||
json={"role": "user"},
|
||
headers=_auth_headers(admin),
|
||
)
|
||
assert response.status_code == 409
|