mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
test: use real email registration adapters (#43225)
Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
1 parent
43af5082d3
commit
7f4e357418
1 file changed
+181
-113
@@ -1,20 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock
|
||||
from dataclasses import asdict
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from services.account.email_registration_service import (
|
||||
AccountEmailRegistrationService,
|
||||
AccountRegistrationGateway,
|
||||
AccountRegistrationPolicyGateway,
|
||||
EmailRegistrationCodeGenerator,
|
||||
EmailRegistrationNotificationGateway,
|
||||
EmailRegistrationSecurityGateway,
|
||||
EmailRegistrationSendLimiter,
|
||||
EmailRegistrationTokenGateway,
|
||||
)
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_application_services import build_application_services
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
from models.account import Account
|
||||
from services.account.email_registration_service import AccountEmailRegistrationService
|
||||
from services.account_errors import (
|
||||
AccountEmailAlreadyInUseError,
|
||||
AccountEmailDomainSuspendedError,
|
||||
@@ -22,59 +19,66 @@ from services.account_errors import (
|
||||
InvalidEmailRegistrationCodeError,
|
||||
InvalidEmailRegistrationTokenError,
|
||||
)
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.account_entities import (
|
||||
AccountEmailRegistrationPhase,
|
||||
AccountEmailRegistrationToken,
|
||||
AccountSessionTokens,
|
||||
AccountSnapshot,
|
||||
)
|
||||
from tests.unit_tests.model_factories import make_account_snapshot
|
||||
from tests.unit_tests.model_factories import make_account
|
||||
|
||||
|
||||
def _account(*, email: str = "stored@example.com") -> AccountSnapshot:
|
||||
return make_account_snapshot(
|
||||
name="Stored Account",
|
||||
email=email,
|
||||
is_password_set=True,
|
||||
initialized_at=datetime(2026, 1, 1),
|
||||
)
|
||||
@pytest.fixture
|
||||
def service(
|
||||
sqlite_session_factory: sessionmaker[Session], redis_transport: tuple[RedisClientWrapper, MagicMock]
|
||||
) -> AccountEmailRegistrationService:
|
||||
redis, commands = redis_transport
|
||||
commands.return_value = 0
|
||||
return build_application_services(
|
||||
database_client=sqlite_session_factory,
|
||||
deployment_edition=DeploymentEdition.CLOUD,
|
||||
initialization_password="",
|
||||
redis=redis,
|
||||
).accounts.email_registration
|
||||
|
||||
|
||||
def _service() -> tuple[AccountEmailRegistrationService, dict[str, Mock]]:
|
||||
dependencies = {
|
||||
"accounts": Mock(spec=AccountRepository),
|
||||
"tokens": Mock(spec=EmailRegistrationTokenGateway),
|
||||
"codes": Mock(spec=EmailRegistrationCodeGenerator),
|
||||
"notifications": Mock(spec=EmailRegistrationNotificationGateway),
|
||||
"send_limits": Mock(spec=EmailRegistrationSendLimiter),
|
||||
"security": Mock(spec=EmailRegistrationSecurityGateway),
|
||||
"account_policy": Mock(spec=AccountRegistrationPolicyGateway),
|
||||
"registration": Mock(spec=AccountRegistrationGateway),
|
||||
@pytest.fixture
|
||||
def boundaries(service: AccountEmailRegistrationService, mocker: MockerFixture) -> dict[str, MagicMock]:
|
||||
mocker.patch("services.account.email_registration_adapters.secrets.randbelow", side_effect=[1, 2, 3, 4, 5, 6])
|
||||
mocker.patch("services.account.email_registration_adapters.send_email_register_mail_task.delay")
|
||||
mocker.patch("services.account.email_registration_adapters.send_email_register_mail_task_when_account_exist.delay")
|
||||
mocker.patch("services.account.email_registration_adapters.TokenManager.revoke_token")
|
||||
return {
|
||||
"read_token": mocker.patch("services.account.email_registration_adapters.TokenManager.get_token_data"),
|
||||
"generate_token": mocker.patch(
|
||||
"services.account.email_registration_adapters.TokenManager.generate_token", return_value="token-1"
|
||||
),
|
||||
"freeze_type": mocker.patch(
|
||||
"services.account.email_registration_adapters.BillingService.get_email_freeze_type", return_value=None
|
||||
),
|
||||
"issue": mocker.spy(service._tokens, "issue"),
|
||||
"revoke": mocker.spy(service._tokens, "revoke"),
|
||||
"find_by_email": mocker.spy(service._accounts, "find_by_email"),
|
||||
"send_code": mocker.spy(service._notifications, "send_code"),
|
||||
"send_account_exists": mocker.spy(service._notifications, "send_account_exists"),
|
||||
"record": mocker.spy(service._send_limits, "record"),
|
||||
"reset_verification_failures": mocker.spy(service._security, "reset_verification_failures"),
|
||||
"record_verification_failure": mocker.spy(service._security, "record_verification_failure"),
|
||||
"reset_login_failures": mocker.spy(service._security, "reset_login_failures"),
|
||||
"create": mocker.spy(service._registration, "create"),
|
||||
"login": mocker.spy(service._registration, "login"),
|
||||
}
|
||||
service = AccountEmailRegistrationService(
|
||||
accounts=dependencies["accounts"],
|
||||
tokens=dependencies["tokens"],
|
||||
codes=dependencies["codes"],
|
||||
notifications=dependencies["notifications"],
|
||||
send_limits=dependencies["send_limits"],
|
||||
security=dependencies["security"],
|
||||
account_policy=dependencies["account_policy"],
|
||||
registration=dependencies["registration"],
|
||||
)
|
||||
dependencies["accounts"].find_by_email.return_value = None
|
||||
dependencies["codes"].generate.return_value = "123456"
|
||||
dependencies["tokens"].issue.return_value = "token-1"
|
||||
dependencies["send_limits"].is_limited.return_value = False
|
||||
dependencies["security"].is_ip_limited.return_value = False
|
||||
dependencies["security"].is_verification_limited.return_value = False
|
||||
dependencies["account_policy"].get_freeze_type.return_value = None
|
||||
return service, dependencies
|
||||
|
||||
|
||||
def test_send_code_uses_case_fallback_account_and_existing_account_notification() -> None:
|
||||
service, dependencies = _service()
|
||||
dependencies["accounts"].find_by_email.return_value = _account(email="Stored@Example.com")
|
||||
def test_send_code_uses_case_fallback_account_and_existing_account_notification(
|
||||
service: AccountEmailRegistrationService,
|
||||
boundaries: dict[str, MagicMock],
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with sqlite_session_factory.begin() as session:
|
||||
# Stored casing differs from both the request and its normalized address.
|
||||
account = make_account(email="Stored@example.com", name="Stored Account")
|
||||
account.normalized_email = "stored@example.com"
|
||||
session.add(account)
|
||||
|
||||
token = service.send_code(
|
||||
remote_ip="127.0.0.1",
|
||||
@@ -83,20 +87,22 @@ def test_send_code_uses_case_fallback_account_and_existing_account_notification(
|
||||
)
|
||||
|
||||
assert token == "token-1"
|
||||
dependencies["accounts"].find_by_email.assert_called_once_with("Stored@Example.com")
|
||||
dependencies["tokens"].issue.assert_called_once_with(
|
||||
AccountEmailRegistrationToken(email="Stored@Example.com", code="123456")
|
||||
boundaries["find_by_email"].assert_called_once_with("Stored@Example.com")
|
||||
boundaries["issue"].assert_called_once_with(
|
||||
AccountEmailRegistrationToken(email="Stored@example.com", code="123456")
|
||||
)
|
||||
dependencies["notifications"].send_account_exists.assert_called_once_with(
|
||||
email="Stored@Example.com",
|
||||
boundaries["send_account_exists"].assert_called_once_with(
|
||||
email="Stored@example.com",
|
||||
account_name="Stored Account",
|
||||
language="zh-Hans",
|
||||
)
|
||||
dependencies["send_limits"].record.assert_called_once_with("Stored@Example.com")
|
||||
boundaries["record"].assert_called_once_with("Stored@example.com")
|
||||
|
||||
|
||||
def test_send_code_normalizes_new_account_email_and_language() -> None:
|
||||
service, dependencies = _service()
|
||||
def test_send_code_normalizes_new_account_email_and_language(
|
||||
service: AccountEmailRegistrationService,
|
||||
boundaries: dict[str, MagicMock],
|
||||
) -> None:
|
||||
|
||||
service.send_code(
|
||||
remote_ip="127.0.0.1",
|
||||
@@ -104,16 +110,18 @@ def test_send_code_normalizes_new_account_email_and_language() -> None:
|
||||
requested_language="unsupported",
|
||||
)
|
||||
|
||||
dependencies["notifications"].send_code.assert_called_once_with(
|
||||
boundaries["send_code"].assert_called_once_with(
|
||||
email="new@example.com",
|
||||
code="123456",
|
||||
language="en-US",
|
||||
)
|
||||
|
||||
|
||||
def test_send_code_rejects_suspended_domain_before_account_lookup() -> None:
|
||||
service, dependencies = _service()
|
||||
dependencies["account_policy"].get_freeze_type.return_value = "email_domain_suspended"
|
||||
def test_send_code_rejects_suspended_domain_before_account_lookup(
|
||||
service: AccountEmailRegistrationService,
|
||||
boundaries: dict[str, MagicMock],
|
||||
) -> None:
|
||||
boundaries["freeze_type"].return_value = "email_domain_suspended"
|
||||
|
||||
with pytest.raises(AccountEmailDomainSuspendedError):
|
||||
service.send_code(
|
||||
@@ -122,16 +130,20 @@ def test_send_code_rejects_suspended_domain_before_account_lookup() -> None:
|
||||
requested_language=None,
|
||||
)
|
||||
|
||||
dependencies["accounts"].find_by_email.assert_not_called()
|
||||
boundaries["find_by_email"].assert_not_called()
|
||||
|
||||
|
||||
def test_verify_code_rotates_token_into_register_phase() -> None:
|
||||
service, dependencies = _service()
|
||||
dependencies["tokens"].get.return_value = AccountEmailRegistrationToken(
|
||||
email="User@Example.com",
|
||||
code="123456",
|
||||
def test_verify_code_rotates_token_into_register_phase(
|
||||
service: AccountEmailRegistrationService,
|
||||
boundaries: dict[str, MagicMock],
|
||||
) -> None:
|
||||
boundaries["read_token"].return_value = asdict(
|
||||
AccountEmailRegistrationToken(
|
||||
email="User@Example.com",
|
||||
code="123456",
|
||||
)
|
||||
)
|
||||
dependencies["tokens"].issue.return_value = "verified-token"
|
||||
boundaries["generate_token"].return_value = "verified-token"
|
||||
|
||||
verification = service.verify_code(
|
||||
email="USER@example.com",
|
||||
@@ -141,41 +153,76 @@ def test_verify_code_rotates_token_into_register_phase() -> None:
|
||||
|
||||
assert verification.email == "user@example.com"
|
||||
assert verification.token == "verified-token"
|
||||
dependencies["tokens"].revoke.assert_called_once_with("pending-token")
|
||||
dependencies["tokens"].issue.assert_called_once_with(
|
||||
boundaries["revoke"].assert_called_once_with("pending-token")
|
||||
boundaries["issue"].assert_called_once_with(
|
||||
AccountEmailRegistrationToken(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
phase=AccountEmailRegistrationPhase.REGISTER,
|
||||
)
|
||||
)
|
||||
dependencies["security"].reset_verification_failures.assert_called_once_with("user@example.com")
|
||||
boundaries["reset_verification_failures"].assert_called_once_with("user@example.com")
|
||||
|
||||
|
||||
def test_verify_code_records_failure_without_consuming_token() -> None:
|
||||
service, dependencies = _service()
|
||||
dependencies["tokens"].get.return_value = AccountEmailRegistrationToken(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
def test_verify_code_records_failure_without_consuming_token(
|
||||
service: AccountEmailRegistrationService,
|
||||
boundaries: dict[str, MagicMock],
|
||||
) -> None:
|
||||
boundaries["read_token"].return_value = asdict(
|
||||
AccountEmailRegistrationToken(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidEmailRegistrationCodeError):
|
||||
service.verify_code(email="user@example.com", code="wrong", token="pending-token")
|
||||
|
||||
dependencies["security"].record_verification_failure.assert_called_once_with("user@example.com")
|
||||
dependencies["tokens"].revoke.assert_not_called()
|
||||
boundaries["record_verification_failure"].assert_called_once_with("user@example.com")
|
||||
boundaries["revoke"].assert_not_called()
|
||||
|
||||
|
||||
def test_register_creates_account_and_logs_it_in() -> None:
|
||||
service, dependencies = _service()
|
||||
dependencies["tokens"].get.return_value = AccountEmailRegistrationToken(
|
||||
email="New@Example.com",
|
||||
code="123456",
|
||||
phase=AccountEmailRegistrationPhase.REGISTER,
|
||||
def test_register_creates_account_and_logs_it_in(
|
||||
service: AccountEmailRegistrationService,
|
||||
boundaries: dict[str, MagicMock],
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
boundaries["read_token"].return_value = asdict(
|
||||
AccountEmailRegistrationToken(
|
||||
email="New@Example.com",
|
||||
code="123456",
|
||||
phase=AccountEmailRegistrationPhase.REGISTER,
|
||||
)
|
||||
)
|
||||
dependencies["registration"].create.return_value = "account-1"
|
||||
expected_tokens = AccountSessionTokens(access_token="access", refresh_token="refresh", csrf_token="csrf")
|
||||
dependencies["registration"].login.return_value = expected_tokens
|
||||
|
||||
def provision_account(
|
||||
*,
|
||||
email: str,
|
||||
name: str,
|
||||
password: str,
|
||||
interface_language: str,
|
||||
timezone: str,
|
||||
ip_address: str,
|
||||
check_normalized_email: bool,
|
||||
) -> Account:
|
||||
assert password == "ValidPass123!"
|
||||
assert ip_address == "127.0.0.1"
|
||||
assert check_normalized_email is True
|
||||
account = make_account(email=email, name=name, interface_language=interface_language, timezone=timezone)
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.add(account)
|
||||
return account
|
||||
|
||||
mocker.patch(
|
||||
"services.account.email_registration_adapters.AccountService.create_account_and_tenant",
|
||||
side_effect=provision_account,
|
||||
)
|
||||
login = mocker.patch(
|
||||
"services.account.email_registration_adapters.AccountService.login",
|
||||
return_value=expected_tokens,
|
||||
)
|
||||
|
||||
tokens = service.register(
|
||||
remote_ip="127.0.0.1",
|
||||
@@ -187,21 +234,32 @@ def test_register_creates_account_and_logs_it_in() -> None:
|
||||
)
|
||||
|
||||
assert tokens == expected_tokens
|
||||
dependencies["tokens"].revoke.assert_called_once_with("verified-token")
|
||||
dependencies["accounts"].find_by_email.assert_called_once_with("New@Example.com")
|
||||
dependencies["registration"].create.assert_called_once_with(
|
||||
with sqlite_session_factory() as session:
|
||||
account = session.get(Account, "account-1")
|
||||
assert account is not None
|
||||
assert (account.email, account.interface_language, account.timezone) == (
|
||||
"new@example.com",
|
||||
"zh-Hans",
|
||||
"Asia/Shanghai",
|
||||
)
|
||||
login.assert_called_once_with("account-1", ip_address="127.0.0.1")
|
||||
boundaries["revoke"].assert_called_once_with("verified-token")
|
||||
boundaries["find_by_email"].assert_called_once_with("New@Example.com")
|
||||
boundaries["create"].assert_called_once_with(
|
||||
email="new@example.com",
|
||||
password="ValidPass123!",
|
||||
interface_language="zh-Hans",
|
||||
timezone="Asia/Shanghai",
|
||||
ip_address="127.0.0.1",
|
||||
)
|
||||
dependencies["registration"].login.assert_called_once_with("account-1", ip_address="127.0.0.1")
|
||||
dependencies["security"].reset_login_failures.assert_called_once_with("new@example.com")
|
||||
boundaries["login"].assert_called_once_with("account-1", ip_address="127.0.0.1")
|
||||
boundaries["reset_login_failures"].assert_called_once_with("new@example.com")
|
||||
|
||||
|
||||
def test_register_rejects_password_mismatch_before_reading_token() -> None:
|
||||
service, dependencies = _service()
|
||||
def test_register_rejects_password_mismatch_before_reading_token(
|
||||
service: AccountEmailRegistrationService,
|
||||
boundaries: dict[str, MagicMock],
|
||||
) -> None:
|
||||
|
||||
with pytest.raises(EmailRegistrationPasswordMismatchError):
|
||||
service.register(
|
||||
@@ -213,14 +271,18 @@ def test_register_rejects_password_mismatch_before_reading_token() -> None:
|
||||
timezone=None,
|
||||
)
|
||||
|
||||
dependencies["tokens"].get.assert_not_called()
|
||||
boundaries["read_token"].assert_not_called()
|
||||
|
||||
|
||||
def test_register_requires_verified_registration_phase() -> None:
|
||||
service, dependencies = _service()
|
||||
dependencies["tokens"].get.return_value = AccountEmailRegistrationToken(
|
||||
email="new@example.com",
|
||||
code="123456",
|
||||
def test_register_requires_verified_registration_phase(
|
||||
service: AccountEmailRegistrationService,
|
||||
boundaries: dict[str, MagicMock],
|
||||
) -> None:
|
||||
boundaries["read_token"].return_value = asdict(
|
||||
AccountEmailRegistrationToken(
|
||||
email="new@example.com",
|
||||
code="123456",
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidEmailRegistrationTokenError):
|
||||
@@ -233,17 +295,23 @@ def test_register_requires_verified_registration_phase() -> None:
|
||||
timezone=None,
|
||||
)
|
||||
|
||||
dependencies["tokens"].revoke.assert_not_called()
|
||||
boundaries["revoke"].assert_not_called()
|
||||
|
||||
|
||||
def test_register_consumes_token_before_rejecting_existing_account() -> None:
|
||||
service, dependencies = _service()
|
||||
dependencies["tokens"].get.return_value = AccountEmailRegistrationToken(
|
||||
email="existing@example.com",
|
||||
code="123456",
|
||||
phase=AccountEmailRegistrationPhase.REGISTER,
|
||||
def test_register_consumes_token_before_rejecting_existing_account(
|
||||
service: AccountEmailRegistrationService,
|
||||
boundaries: dict[str, MagicMock],
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
boundaries["read_token"].return_value = asdict(
|
||||
AccountEmailRegistrationToken(
|
||||
email="existing@example.com",
|
||||
code="123456",
|
||||
phase=AccountEmailRegistrationPhase.REGISTER,
|
||||
)
|
||||
)
|
||||
dependencies["accounts"].find_by_email.return_value = _account(email="existing@example.com")
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.add(make_account(email="existing@example.com", name="Stored Account"))
|
||||
|
||||
with pytest.raises(AccountEmailAlreadyInUseError):
|
||||
service.register(
|
||||
@@ -255,5 +323,5 @@ def test_register_consumes_token_before_rejecting_existing_account() -> None:
|
||||
timezone=None,
|
||||
)
|
||||
|
||||
dependencies["tokens"].revoke.assert_called_once_with("verified-token")
|
||||
dependencies["registration"].create.assert_not_called()
|
||||
boundaries["revoke"].assert_called_once_with("verified-token")
|
||||
boundaries["create"].assert_not_called()
|
||||
Reference in new issue
Block a user