feat(auth): согласие на обработку персональных данных при регистрации
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled

Отключаемый модуль (instance_settings.consent_policy): галочка + ссылка на
публичную страницу регламента на форме регистрации, редактируемый в админке
текст с типовым шаблоном по умолчанию (плейсхолдеры под организацию, не
проходил юридическую проверку), версия текста растёт при каждой правке.
Факт согласия хранится в users (consent_version, consent_given_at) — второй
эшелон проверки на сервере, как и для отключаемых модулей ранее. Дефолт
(выключено) сохраняет поведение существующих инсталляций, у уже
зарегистрированных пользователей согласие не запрашивается.
This commit is contained in:
2026-08-04 22:20:00 +03:00
parent 0e029a2bf8
commit 4f82ebe17a
19 changed files with 615 additions and 9 deletions

View File

@@ -40,7 +40,11 @@ async def _reset_registration_gating(db_session: AsyncSession) -> None:
`conftest.py`).
"""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(registration_team_choice=False, registration_email_domain_enabled=False)
SettingsUpdateIn(
registration_team_choice=False,
registration_email_domain_enabled=False,
consent_required=False,
)
)
await db_session.commit()
@@ -495,6 +499,90 @@ async def test_register_no_reply_to_when_contact_email_disabled(
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:

View File

@@ -48,6 +48,7 @@ from services.instance_settings import (
BootstrapOverrides,
InstanceSettingsService,
InvalidAiLevelError,
InvalidConsentPolicyError,
InvalidContactEmailError,
InvalidEmailDomainError,
InvalidTimezoneError,
@@ -73,6 +74,7 @@ _MANAGED_KEYS = (
"registration_email_domain",
"contact_email",
"media_limits",
"consent_policy",
)
@@ -130,6 +132,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
"registration_email_domain",
"contact_email",
"media_limits",
"consent_policy",
}
cfg = await service.get()
assert cfg.transcriber.provider == "faster_whisper_cpu"
@@ -149,6 +152,13 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
# потолку `StageGrid` (5×5).
assert cfg.media_limits.publish_quality_cap == "off"
assert cfg.media_limits.stage_max_tiles == 25
# Согласие на обработку персональных данных выключено по умолчанию
# (дефолт сохраняет поведение существующих инсталляций), но дефолтный
# текст-шаблон уже на месте — публичная страница регламента осмысленна
# даже при выключенном модуле.
assert cfg.consent_required is False
assert cfg.consent_policy_text != ""
assert cfg.consent_policy_version == 1
async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
@@ -351,6 +361,61 @@ async def test_registration_team_choice_toggle(
assert reloaded.registration_team_choice is True
async def test_consent_policy_toggle_without_text_change_keeps_version(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Включение флага без правки текста не увеличивает версию."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
baseline = await service.get()
assert baseline.consent_required is False
cfg = await service.update(SettingsUpdateIn(consent_required=True))
assert cfg.consent_required is True
assert cfg.consent_policy_version == baseline.consent_policy_version
reloaded = await service.get()
assert reloaded.consent_required is True
assert reloaded.consent_policy_version == baseline.consent_policy_version
async def test_consent_policy_text_change_bumps_version(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Правка текста регламента увеличивает версию — иначе «версия согласия» в БД бессмысленна."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
baseline = await service.get()
cfg = await service.update(SettingsUpdateIn(consent_policy_text="Новый текст регламента"))
assert cfg.consent_policy_text == "Новый текст регламента"
assert cfg.consent_policy_version == baseline.consent_policy_version + 1
# Повторное сохранение ТОГО ЖЕ текста версию больше не двигает.
cfg2 = await service.update(SettingsUpdateIn(consent_policy_text="Новый текст регламента"))
assert cfg2.consent_policy_version == cfg.consent_policy_version
reloaded = await service.get()
assert reloaded.consent_policy_version == cfg.consent_policy_version
async def test_consent_policy_enable_with_empty_text_rejected(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Нельзя включить обязательное согласие, если текст регламента пуст."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidConsentPolicyError):
await service.update(
SettingsUpdateIn(consent_required=True, consent_policy_text=" ")
)
cfg = await service.get()
assert cfg.consent_required is False
async def test_registration_email_domain_enable_without_domain_rejected(
db_session: AsyncSession, clean_instance_settings: None
) -> None: