Files
vidconf/backend/tests/test_auth.py
Max Ronzhin 4f82ebe17a
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
feat(auth): согласие на обработку персональных данных при регистрации
Отключаемый модуль (instance_settings.consent_policy): галочка + ссылка на
публичную страницу регламента на форме регистрации, редактируемый в админке
текст с типовым шаблоном по умолчанию (плейсхолдеры под организацию, не
проходил юридическую проверку), версия текста растёт при каждой правке.
Факт согласия хранится в users (consent_version, consent_given_at) — второй
эшелон проверки на сервере, как и для отключаемых модулей ранее. Дефолт
(выключено) сохраняет поведение существующих инсталляций, у уже
зарегистрированных пользователей согласие не запрашивается.
2026-08-04 22:20:00 +03:00

616 lines
24 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/auth`: полный цикл регистрация → вход,
ротация/reuse refresh-токена, закрытые эндпоинты без JWT.
"""
import re
import uuid
from collections.abc import AsyncGenerator, Sequence
from typing import Annotated
import httpx
import pytest_asyncio
from argon2 import PasswordHasher
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 core.security import needs_rehash
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,
consent_required=False,
)
)
await db_session.commit()
class _CapturingEmailBackend:
"""Тестовый `EmailBackend`: сохраняет отправленные письма в памяти вместо лога."""
def __init__(self) -> None:
self.sent: list[tuple[str, str, str]] = []
self.reply_to: list[str | None] = []
async def send(
self,
*,
to: str,
subject: str,
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
reply_to: str | None = None,
) -> None:
self.sent.append((to, subject, body))
self.reply_to.append(reply_to)
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_domains"] == []
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_domains_when_enabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""При включённой верификации домена `registration-options` отдаёт все эталонные домены."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True,
registration_email_domains=["example.com", "corp.example"],
)
)
await db_session.commit()
response = await client.get("/api/v1/auth/registration-options")
assert response.status_code == 200, response.text
assert response.json()["email_domains"] == ["example.com", "corp.example"]
async def test_registration_options_email_domains_empty_when_disabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Выключенная верификация домена — `email_domains` всегда пуст, даже если домены сохранены."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domains=["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_domains"] == []
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_domains=["example.com", "corp.example"],
)
)
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_domains=["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_with_second_domain_in_list_succeeds(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Email подходит, если совпадает с ЛЮБЫМ доменом из списка — не только с первым."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True,
registration_email_domains=["example.com", "corp.example"],
)
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={
"email": "dave@corp.example",
"name_user": "Dave",
"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
async def test_register_sets_reply_to_when_contact_email_enabled(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Контактный адрес инстанса включён — письмо подтверждения регистрации несёт `Reply-To`."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(contact_email_enabled=True, contact_email="contact@vidconf.example")
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={"email": "dave@example.com", "name_user": "Dave", "password": "supersecret1"},
)
assert response.status_code == 201, response.text
assert email_backend.reply_to[-1] == "contact@vidconf.example"
async def test_register_no_reply_to_when_contact_email_disabled(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None:
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
response = await client.post(
"/api/v1/auth/register",
json={"email": "erin@example.com", "name_user": "Erin", "password": "supersecret1"},
)
assert response.status_code == 201, response.text
assert email_backend.reply_to[-1] is None
async def test_registration_options_returns_consent_fields(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""`consent_text`/`consent_version` отдаются ВСЕГДА (нужны странице регламента),
`consent_required` — по факту настройки инстанса."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(consent_policy_text="Текст регламента для теста")
)
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["consent_required"] is False
assert body["consent_text"] == "Текст регламента для теста"
assert isinstance(body["consent_version"], int)
async def test_register_without_consent_when_required_returns_400(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Сервер отказывает в регистрации без галочки, даже если фронт её не прислал —
второй эшелон проверки (тот же принцип, что `hand_queue_disabled` в 0.0.28)."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(consent_required=True, consent_policy_text="Текст регламента")
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={"email": "no-consent@example.com", "name_user": "No Consent", "password": "supersecret1"},
)
assert response.status_code == 400
assert response.json()["detail"] == "consent_required"
result = await db_session.execute(select(User).where(User.email == "no-consent@example.com"))
assert result.scalar_one_or_none() is None
async def test_register_with_consent_when_required_writes_version_and_date(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Принятое согласие пишется в БД вместе с редакцией регламента и датой."""
cfg = await InstanceSettingsService(db_session).update(
SettingsUpdateIn(consent_required=True, consent_policy_text="Текст регламента для приёмки")
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={
"email": "with-consent@example.com",
"name_user": "With Consent",
"password": "supersecret1",
"consent_accepted": True,
},
)
assert response.status_code == 201, response.text
result = await db_session.execute(select(User).where(User.email == "with-consent@example.com"))
created = result.scalar_one()
assert created.consent_version == cfg.consent_policy_version
assert created.consent_given_at is not None
async def test_register_without_consent_when_module_disabled_succeeds_and_leaves_it_null(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Модуль выключен (дефолт `_reset_registration_gating`) — регистрация не требует
галочки, `consent_version`/`consent_given_at` остаются `NULL`."""
response = await client.post(
"/api/v1/auth/register",
json={"email": "consent-disabled@example.com", "name_user": "Consent Disabled", "password": "supersecret1"},
)
assert response.status_code == 201, response.text
result = await db_session.execute(
select(User).where(User.email == "consent-disabled@example.com")
)
created = result.scalar_one()
assert created.consent_version is None
assert created.consent_given_at is None
async def test_login_rehashes_legacy_password(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Вход с паролем, захэшированным старыми параметрами, перевыпускает хэш.
Параметры argon2 зашиты в саму строку хэша, поэтому смена настроек
(0.0.17: дефолты библиотеки → рекомендации OWASP) сама по себе не ускоряет
проверку уже существующих паролей. Миграция идёт лениво — при первом
успешном входе, когда открытый пароль есть на руках.
"""
email = "legacy-hash@example.com"
password = "supersecret1"
await _register_and_verify(client, email_backend, email=email, password=password)
# Подменяем хэш на выданный прежними параметрами (t=3, m=64 МБ, p=4).
legacy_hash = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4).hash(password)
user = await db_session.scalar(select(User).where(User.email == email))
assert user is not None
user.password_hash = legacy_hash
await db_session.commit()
response = await client.post(
"/api/v1/auth/token", data={"username": email, "password": password}
)
assert response.status_code == 200, response.text
await db_session.refresh(user)
assert user.password_hash != legacy_hash, "старый хэш не был перевыпущен"
assert "m=19456" in user.password_hash
assert needs_rehash(user.password_hash) is False