feat(auth): несколько эталонных mail-доменов для верификации регистрации
Настройка «Эталон mail-домена» теперь хранит список доменов вместо
одного — email при регистрации принимается, если совпадает с любым из
них. Старое значение в БД ({"domain": str|None}) читается прозрачно
(обратная совместимость без Alembic-миграции) и переписывается в новую
форму ({"domains": [...]}) при первом же сохранении настроек. В админке
добавление/удаление доменов — списком чипов; на экране регистрации
подсказка о несовпадении домена перечисляет все эталонные варианты.
This commit is contained in:
@@ -238,7 +238,7 @@ async def test_registration_options_disabled_by_default(
|
||||
body = response.json()
|
||||
assert body["team_choice_enabled"] is False
|
||||
assert body["teams"] == []
|
||||
assert body["email_domain"] is None
|
||||
assert body["email_domains"] == []
|
||||
|
||||
|
||||
async def test_registration_options_enabled_returns_teams_sorted_by_name(
|
||||
@@ -343,29 +343,30 @@ async def test_register_with_unknown_team_id_returns_400(
|
||||
# --- Верификация регистрирующихся по домену email -------------------------------------
|
||||
|
||||
|
||||
async def test_registration_options_returns_email_domain_when_enabled(
|
||||
async def test_registration_options_returns_email_domains_when_enabled(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""При включённой верификации домена `registration-options` отдаёт эталонный домен."""
|
||||
"""При включённой верификации домена `registration-options` отдаёт все эталонные домены."""
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(
|
||||
registration_email_domain_enabled=True, registration_email_domain="example.com"
|
||||
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_domain"] == "example.com"
|
||||
assert response.json()["email_domains"] == ["example.com", "corp.example"]
|
||||
|
||||
|
||||
async def test_registration_options_email_domain_null_when_disabled(
|
||||
async def test_registration_options_email_domains_empty_when_disabled(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Выключенная верификация домена — `email_domain` всегда `null`, даже если домен сохранён."""
|
||||
"""Выключенная верификация домена — `email_domains` всегда пуст, даже если домены сохранены."""
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(
|
||||
registration_email_domain_enabled=True, registration_email_domain="example.com"
|
||||
registration_email_domain_enabled=True, registration_email_domains=["example.com"]
|
||||
)
|
||||
)
|
||||
await InstanceSettingsService(db_session).update(
|
||||
@@ -375,7 +376,7 @@ async def test_registration_options_email_domain_null_when_disabled(
|
||||
|
||||
response = await client.get("/api/v1/auth/registration-options")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["email_domain"] is None
|
||||
assert response.json()["email_domains"] == []
|
||||
|
||||
|
||||
async def test_register_with_foreign_domain_when_verification_enabled_returns_400(
|
||||
@@ -383,7 +384,8 @@ async def test_register_with_foreign_domain_when_verification_enabled_returns_40
|
||||
) -> None:
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(
|
||||
registration_email_domain_enabled=True, registration_email_domain="example.com"
|
||||
registration_email_domain_enabled=True,
|
||||
registration_email_domains=["example.com", "corp.example"],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
@@ -405,7 +407,7 @@ async def test_register_with_matching_domain_case_insensitive_succeeds(
|
||||
) -> None:
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(
|
||||
registration_email_domain_enabled=True, registration_email_domain="example.com"
|
||||
registration_email_domain_enabled=True, registration_email_domains=["example.com"]
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
@@ -421,6 +423,29 @@ async def test_register_with_matching_domain_case_insensitive_succeeds(
|
||||
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:
|
||||
|
||||
@@ -134,7 +134,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
|
||||
assert cfg.display_timezone == "Europe/Moscow"
|
||||
assert cfg.registration_team_choice is False
|
||||
assert cfg.registration_email_domain_enabled is False
|
||||
assert cfg.registration_email_domain is None
|
||||
assert cfg.registration_email_domains == []
|
||||
assert cfg.contact_email_enabled is False
|
||||
assert cfg.contact_email is None
|
||||
|
||||
@@ -326,7 +326,7 @@ async def test_registration_team_choice_toggle(
|
||||
async def test_registration_email_domain_enable_without_domain_rejected(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
"""Включение верификации без домена (ни в патче, ни ранее сохранённого) → 400."""
|
||||
"""Включение верификации без доменов (ни в патче, ни ранее сохранённых) → 400."""
|
||||
service = InstanceSettingsService(db_session)
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
|
||||
@@ -335,7 +335,24 @@ async def test_registration_email_domain_enable_without_domain_rejected(
|
||||
|
||||
cfg = await service.get()
|
||||
assert cfg.registration_email_domain_enabled is False
|
||||
assert cfg.registration_email_domain is None
|
||||
assert cfg.registration_email_domains == []
|
||||
|
||||
|
||||
async def test_registration_email_domain_enable_with_empty_list_rejected(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
"""Пустой список доменов при включении верификации — та же ошибка, что и
|
||||
отсутствие поля (не молчаливое отключение проверки)."""
|
||||
service = InstanceSettingsService(db_session)
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
|
||||
with pytest.raises(InvalidEmailDomainError):
|
||||
await service.update(
|
||||
SettingsUpdateIn(registration_email_domain_enabled=True, registration_email_domains=[])
|
||||
)
|
||||
|
||||
cfg = await service.get()
|
||||
assert cfg.registration_email_domain_enabled is False
|
||||
|
||||
|
||||
async def test_registration_email_domain_rejects_invalid_pattern(
|
||||
@@ -345,48 +362,106 @@ async def test_registration_email_domain_rejects_invalid_pattern(
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
|
||||
with pytest.raises(InvalidEmailDomainError):
|
||||
await service.update(SettingsUpdateIn(registration_email_domain="not a domain"))
|
||||
await service.update(SettingsUpdateIn(registration_email_domains=["not a domain"]))
|
||||
|
||||
cfg = await service.get()
|
||||
assert cfg.registration_email_domain is None
|
||||
assert cfg.registration_email_domains == []
|
||||
|
||||
|
||||
async def test_registration_email_domain_normalizes_input(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
"""`@Corp.RU ` нормализуется в `corp.ru` (strip, убрать «@», lower)."""
|
||||
"""`@Corp.RU ` нормализуется в `corp.ru` (strip, убрать «@», lower); пустые строки
|
||||
отбрасываются, дубликаты схлопываются."""
|
||||
service = InstanceSettingsService(db_session)
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
|
||||
cfg = await service.update(
|
||||
SettingsUpdateIn(
|
||||
registration_email_domain_enabled=True, registration_email_domain="@Corp.RU "
|
||||
registration_email_domain_enabled=True,
|
||||
registration_email_domains=["@Corp.RU ", "", "corp.ru", "Acme.IO"],
|
||||
)
|
||||
)
|
||||
|
||||
assert cfg.registration_email_domain_enabled is True
|
||||
assert cfg.registration_email_domain == "corp.ru"
|
||||
assert cfg.registration_email_domains == ["corp.ru", "acme.io"]
|
||||
|
||||
reloaded = await service.get()
|
||||
assert reloaded.registration_email_domain == "corp.ru"
|
||||
assert reloaded.registration_email_domains == ["corp.ru", "acme.io"]
|
||||
|
||||
|
||||
async def test_registration_email_domain_can_be_disabled_keeping_stored_domain(
|
||||
async def test_registration_email_domain_can_be_disabled_keeping_stored_domains(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
"""Выключение верификации без передачи домена не требует домена и не роняет валидацию."""
|
||||
"""Выключение верификации без передачи доменов не требует их и не роняет валидацию."""
|
||||
service = InstanceSettingsService(db_session)
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
await service.update(
|
||||
SettingsUpdateIn(
|
||||
registration_email_domain_enabled=True, registration_email_domain="acme.io"
|
||||
registration_email_domain_enabled=True, registration_email_domains=["acme.io"]
|
||||
)
|
||||
)
|
||||
|
||||
cfg = await service.update(SettingsUpdateIn(registration_email_domain_enabled=False))
|
||||
|
||||
assert cfg.registration_email_domain_enabled is False
|
||||
assert cfg.registration_email_domain == "acme.io"
|
||||
assert cfg.registration_email_domains == ["acme.io"]
|
||||
|
||||
|
||||
async def test_registration_email_domain_reads_legacy_single_domain_shape(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
"""До многодоменной поддержки в БД хранилась форма `{"domain": str|None}` (один
|
||||
домен, без миграции на проде) — чтение должно разворачивать её в список из
|
||||
одного элемента (см. `_extract_email_domains`)."""
|
||||
service = InstanceSettingsService(db_session)
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
stmt = (
|
||||
pg_insert(InstanceSetting)
|
||||
.values(
|
||||
key="registration_email_domain",
|
||||
value={"enabled": True, "domain": "legacy.example"},
|
||||
)
|
||||
.on_conflict_do_update(
|
||||
index_elements=["key"],
|
||||
set_={"value": {"enabled": True, "domain": "legacy.example"}},
|
||||
)
|
||||
)
|
||||
await db_session.execute(stmt)
|
||||
await db_session.commit()
|
||||
|
||||
cfg = await service.get()
|
||||
|
||||
assert cfg.registration_email_domain_enabled is True
|
||||
assert cfg.registration_email_domains == ["legacy.example"]
|
||||
|
||||
|
||||
async def test_registration_email_domain_rewrites_legacy_shape_on_update(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
"""Первое же сохранение после чтения старой формы переписывает строку в
|
||||
новую (`domains: [...]`), а не оставляет legacy `domain` рядом."""
|
||||
service = InstanceSettingsService(db_session)
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
stmt = (
|
||||
pg_insert(InstanceSetting)
|
||||
.values(
|
||||
key="registration_email_domain",
|
||||
value={"enabled": True, "domain": "legacy.example"},
|
||||
)
|
||||
.on_conflict_do_update(
|
||||
index_elements=["key"],
|
||||
set_={"value": {"enabled": True, "domain": "legacy.example"}},
|
||||
)
|
||||
)
|
||||
await db_session.execute(stmt)
|
||||
await db_session.commit()
|
||||
|
||||
await service.update(SettingsUpdateIn(registration_email_domains=["new.example"]))
|
||||
|
||||
row = await db_session.get(InstanceSetting, "registration_email_domain")
|
||||
assert row is not None
|
||||
assert row.value == {"enabled": True, "domains": ["new.example"]}
|
||||
|
||||
|
||||
async def test_contact_email_enable_without_email_rejected(
|
||||
|
||||
Reference in New Issue
Block a user