first commit
This commit is contained in:
434
backend/tests/test_auth.py
Normal file
434
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,434 @@
|
||||
"""Интеграционные тесты для `/api/v1/auth`: полный цикл регистрация → вход,
|
||||
ротация/reuse refresh-токена, закрытые эндпоинты без JWT.
|
||||
"""
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator, Sequence
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
import pytest_asyncio
|
||||
from fastapi import Depends, FastAPI
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.auth import get_auth_service
|
||||
from core.db import get_session
|
||||
from core.redis import redis_client
|
||||
from models.team import Team
|
||||
from models.user import User
|
||||
from services.auth import AuthService
|
||||
from services.email import EmailAttachment
|
||||
from services.instance_settings import InstanceSettingsService, SettingsUpdateIn
|
||||
|
||||
REFRESH_COOKIE = "refresh_token"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _reset_registration_gating(db_session: AsyncSession) -> None:
|
||||
"""Сбросить перед каждым тестом настройки, влияющие на публичную регистрацию.
|
||||
|
||||
`registration_team_choice`/`registration_email_domain_enabled` — общая
|
||||
dev-БД, строки могут быть легитимными данными разработчика (включены
|
||||
вручную через админку) — тесты, которым это не важно, не должны зависеть
|
||||
от текущего состояния общей БД. Тесты, которым важно обратное, переключают
|
||||
настройку сами внутри тела теста уже после этого сброса (та же
|
||||
транзакция — `db_session` никогда не коммитится в реальную БД, см.
|
||||
`conftest.py`).
|
||||
"""
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(registration_team_choice=False, registration_email_domain_enabled=False)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
class _CapturingEmailBackend:
|
||||
"""Тестовый `EmailBackend`: сохраняет отправленные письма в памяти вместо лога."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[tuple[str, str, str]] = []
|
||||
|
||||
async def send(
|
||||
self,
|
||||
*,
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
html_body: str | None = None,
|
||||
attachments: Sequence[EmailAttachment] = (),
|
||||
) -> None:
|
||||
self.sent.append((to, subject, body))
|
||||
|
||||
|
||||
def _extract_verification_token(email_body: str) -> str:
|
||||
match = re.search(r"token=([\w\-]+)", email_body)
|
||||
assert match is not None, f"токен подтверждения не найден в письме: {email_body!r}"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def email_backend(app: FastAPI) -> AsyncGenerator[_CapturingEmailBackend, None]:
|
||||
"""Подменить email-бэкенд `AuthService` на перехватывающий (для чтения ссылки verify).
|
||||
|
||||
`app` — свежий экземпляр на каждый тест (фикстура function-scoped), поэтому
|
||||
override не нужно откатывать вручную.
|
||||
"""
|
||||
backend = _CapturingEmailBackend()
|
||||
|
||||
async def _get_auth_service_override(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> AuthService:
|
||||
return AuthService(session=session, redis=redis_client, email_backend=backend)
|
||||
|
||||
app.dependency_overrides[get_auth_service] = _get_auth_service_override
|
||||
yield backend
|
||||
|
||||
|
||||
async def _register_and_verify(
|
||||
client: httpx.AsyncClient,
|
||||
email_backend: _CapturingEmailBackend,
|
||||
*,
|
||||
email: str = "alice@example.com",
|
||||
name_user: str = "Alice",
|
||||
password: str = "supersecret1",
|
||||
) -> None:
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "name_user": name_user, "password": password},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
|
||||
assert email_backend.sent, "письмо с подтверждением не было отправлено"
|
||||
_, _, body = email_backend.sent[-1]
|
||||
token = _extract_verification_token(body)
|
||||
|
||||
verify_response = await client.post("/api/v1/auth/verify-email", json={"token": token})
|
||||
assert verify_response.status_code == 204, verify_response.text
|
||||
|
||||
|
||||
async def test_full_cycle_register_verify_login_me(
|
||||
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
|
||||
) -> None:
|
||||
"""Регистрация → подтверждение email → вход → GET /users/me с access-токеном."""
|
||||
await _register_and_verify(client, email_backend)
|
||||
|
||||
login_response = await client.post(
|
||||
"/api/v1/auth/token",
|
||||
data={"username": "alice@example.com", "password": "supersecret1"},
|
||||
)
|
||||
assert login_response.status_code == 200, login_response.text
|
||||
body = login_response.json()
|
||||
assert body["token_type"] == "bearer"
|
||||
access_token = body["access_token"]
|
||||
assert REFRESH_COOKIE in login_response.cookies
|
||||
|
||||
me_response = await client.get(
|
||||
"/api/v1/users/me", headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
assert me_response.status_code == 200
|
||||
me_body = me_response.json()
|
||||
assert me_body["email"] == "alice@example.com"
|
||||
assert me_body["name_user"] == "Alice"
|
||||
assert me_body["role"] == "user"
|
||||
|
||||
|
||||
async def test_register_duplicate_email_returns_409(
|
||||
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
|
||||
) -> None:
|
||||
await _register_and_verify(client, email_backend, email="dup@example.com")
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "dup@example.com", "name_user": "Dup", "password": "supersecret1"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
async def test_login_without_verification_returns_403(
|
||||
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
|
||||
) -> None:
|
||||
register_response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "bob@example.com", "name_user": "Bob", "password": "supersecret1"},
|
||||
)
|
||||
assert register_response.status_code == 201
|
||||
|
||||
login_response = await client.post(
|
||||
"/api/v1/auth/token",
|
||||
data={"username": "bob@example.com", "password": "supersecret1"},
|
||||
)
|
||||
assert login_response.status_code == 403
|
||||
|
||||
|
||||
async def test_verify_email_with_invalid_token_returns_400(
|
||||
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
|
||||
) -> None:
|
||||
response = await client.post("/api/v1/auth/verify-email", json={"token": "does-not-exist"})
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
async def test_protected_endpoint_without_jwt_returns_401(client: httpx.AsyncClient) -> None:
|
||||
response = await client.get("/api/v1/users/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_refresh_rotates_token_and_rejects_reuse(
|
||||
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
|
||||
) -> None:
|
||||
await _register_and_verify(client, email_backend, email="carol@example.com")
|
||||
login_response = await client.post(
|
||||
"/api/v1/auth/token",
|
||||
data={"username": "carol@example.com", "password": "supersecret1"},
|
||||
)
|
||||
assert login_response.status_code == 200
|
||||
old_refresh_token = client.cookies.get(REFRESH_COOKIE)
|
||||
assert old_refresh_token is not None
|
||||
|
||||
refresh_response = await client.post("/api/v1/auth/refresh")
|
||||
assert refresh_response.status_code == 200
|
||||
new_refresh_token = client.cookies.get(REFRESH_COOKIE)
|
||||
assert new_refresh_token is not None
|
||||
assert new_refresh_token != old_refresh_token
|
||||
|
||||
# Повторное предъявление уже потраченного (старого) refresh-токена — reuse.
|
||||
client.cookies.set(REFRESH_COOKIE, old_refresh_token)
|
||||
reuse_response = await client.post("/api/v1/auth/refresh")
|
||||
assert reuse_response.status_code == 401
|
||||
|
||||
|
||||
async def test_refresh_without_cookie_returns_401(client: httpx.AsyncClient) -> None:
|
||||
response = await client.post("/api/v1/auth/refresh")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_logout_revokes_refresh_token(
|
||||
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
|
||||
) -> None:
|
||||
await _register_and_verify(client, email_backend, email="dave@example.com")
|
||||
login_response = await client.post(
|
||||
"/api/v1/auth/token",
|
||||
data={"username": "dave@example.com", "password": "supersecret1"},
|
||||
)
|
||||
assert login_response.status_code == 200
|
||||
|
||||
logout_response = await client.post("/api/v1/auth/logout")
|
||||
assert logout_response.status_code == 204
|
||||
|
||||
refresh_response = await client.post("/api/v1/auth/refresh")
|
||||
assert refresh_response.status_code == 401
|
||||
|
||||
|
||||
# --- Выбор команды при регистрации ---------------------------------------------
|
||||
|
||||
|
||||
async def test_registration_options_disabled_by_default(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Настройка `registration_team_choice` выключена (см. `_reset_registration_gating`)
|
||||
— команды не отдаются."""
|
||||
db_session.add(Team(name=f"Zeta Team {uuid.uuid4()}"))
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get("/api/v1/auth/registration-options")
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["team_choice_enabled"] is False
|
||||
assert body["teams"] == []
|
||||
assert body["email_domain"] is None
|
||||
|
||||
|
||||
async def test_registration_options_enabled_returns_teams_sorted_by_name(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Список команд отсортирован по названию; проверяем взаимный порядок своих команд
|
||||
(не полное равенство списка — в общей dev-БД могут быть и другие команды)."""
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
name_alpha, name_zeta = f"Alpha-{suffix}", f"Zeta-{suffix}"
|
||||
db_session.add_all([Team(name=name_zeta), Team(name=name_alpha)])
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(registration_team_choice=True)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get("/api/v1/auth/registration-options")
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["team_choice_enabled"] is True
|
||||
names = [team["name"] for team in body["teams"]]
|
||||
assert names == sorted(names)
|
||||
assert name_alpha in names and name_zeta in names
|
||||
assert names.index(name_alpha) < names.index(name_zeta)
|
||||
|
||||
|
||||
async def test_register_without_team_id_still_works(
|
||||
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
|
||||
) -> None:
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "no-team@example.com", "name_user": "No Team", "password": "supersecret1"},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
|
||||
|
||||
async def test_register_with_team_id_when_enabled_assigns_team(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
|
||||
) -> None:
|
||||
team = Team(name=f"Engineering {uuid.uuid4()}")
|
||||
db_session.add(team)
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(registration_team_choice=True)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "with-team@example.com",
|
||||
"name_user": "With Team",
|
||||
"password": "supersecret1",
|
||||
"team_id": str(team.id),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
|
||||
result = await db_session.execute(select(User).where(User.email == "with-team@example.com"))
|
||||
created = result.scalar_one()
|
||||
assert created.team_id == team.id
|
||||
|
||||
|
||||
async def test_register_with_team_id_when_disabled_returns_400(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
|
||||
) -> None:
|
||||
# `registration_team_choice` выключена `_reset_registration_gating`.
|
||||
team = Team(name=f"Disabled Choice Team {uuid.uuid4()}")
|
||||
db_session.add(team)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "team-disabled@example.com",
|
||||
"name_user": "Team Disabled",
|
||||
"password": "supersecret1",
|
||||
"team_id": str(team.id),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
async def test_register_with_unknown_team_id_returns_400(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
|
||||
) -> None:
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(registration_team_choice=True)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "unknown-team@example.com",
|
||||
"name_user": "Unknown Team",
|
||||
"password": "supersecret1",
|
||||
"team_id": "00000000-0000-0000-0000-000000000000",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# --- Верификация регистрирующихся по домену email -------------------------------------
|
||||
|
||||
|
||||
async def test_registration_options_returns_email_domain_when_enabled(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""При включённой верификации домена `registration-options` отдаёт эталонный домен."""
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(
|
||||
registration_email_domain_enabled=True, registration_email_domain="example.com"
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get("/api/v1/auth/registration-options")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["email_domain"] == "example.com"
|
||||
|
||||
|
||||
async def test_registration_options_email_domain_null_when_disabled(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Выключенная верификация домена — `email_domain` всегда `null`, даже если домен сохранён."""
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(
|
||||
registration_email_domain_enabled=True, registration_email_domain="example.com"
|
||||
)
|
||||
)
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(registration_email_domain_enabled=False)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get("/api/v1/auth/registration-options")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["email_domain"] is None
|
||||
|
||||
|
||||
async def test_register_with_foreign_domain_when_verification_enabled_returns_400(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(
|
||||
registration_email_domain_enabled=True, registration_email_domain="example.com"
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "alice@other.com",
|
||||
"name_user": "Alice",
|
||||
"password": "supersecret1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "invalid_email_domain"
|
||||
|
||||
|
||||
async def test_register_with_matching_domain_case_insensitive_succeeds(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
|
||||
) -> None:
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(
|
||||
registration_email_domain_enabled=True, registration_email_domain="example.com"
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "bob@EXAMPLE.COM",
|
||||
"name_user": "Bob",
|
||||
"password": "supersecret1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
|
||||
|
||||
async def test_register_any_domain_allowed_when_verification_disabled(
|
||||
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
|
||||
) -> None:
|
||||
"""Выключенная верификация (`_reset_registration_gating`) — любой домен email
|
||||
проходит регистрацию."""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "carol@anything.example",
|
||||
"name_user": "Carol",
|
||||
"password": "supersecret1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
Reference in New Issue
Block a user