refactor(api): move account lifecycle into application services (#40438)

This commit is contained in:
Byron.wang
2026-08-21 08:05:43 +00:00
committed by GitHub
parent 92978f6d9f
commit 3b05b5e553
28 changed files with 1022 additions and 663 deletions
+2
View File
@@ -98,7 +98,9 @@ name = Account application services and contracts are framework and persistence
type = forbidden
source_modules =
services.account_avatar_service
services.account_deletion_service
services.account_errors
services.account_initialization_service
services.account_integration_service
services.account_password_service
services.account_ports
+6 -4
View File
@@ -22,6 +22,7 @@ from models.account import TenantAccountRole
def console_account_admission[T, **P, R](
*,
editions: frozenset[DeploymentEdition] | None = None,
require_initialized: bool = True,
require_valid_enterprise_license: bool = False,
allowed_roles: frozenset[TenantAccountRole] | None = None,
rbac_resource_scope: RBACResourceScope | None = None,
@@ -34,9 +35,9 @@ def console_account_admission[T, **P, R](
"""Declare Console account admission and inject a stable RequestContext.
All combinations use this decorator factory. Requirements are data, while
the execution order stays fixed: edition, setup, login/CSRF, account
initialization, optional enterprise license, role/RBAC checks, then context
construction.
the execution order stays fixed: edition, setup, login/CSRF, optional
account initialization, optional enterprise license, role/RBAC checks, then
context construction.
"""
if (rbac_resource_scope is None) != (rbac_permission is None):
@@ -72,7 +73,8 @@ def console_account_admission[T, **P, R](
admitted: Callable[Concatenate[T, P], R | Response] = inject_request_context
if require_valid_enterprise_license:
admitted = enterprise_license_required(admitted)
admitted = account_initialization_required(admitted)
if require_initialized:
admitted = account_initialization_required(admitted)
admitted = login_required(admitted)
admitted = setup_required(admitted)
+37 -56
View File
@@ -9,10 +9,8 @@ from flask import request
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic.json_schema import SkipJsonSchema
from sqlalchemy import select
from werkzeug.exceptions import NotFound
from configs import dify_config
from constants.languages import supported_language
from controllers.common.fields import (
AvatarUrlResponse,
@@ -25,6 +23,7 @@ from controllers.console import console_ns
from controllers.console.auth.error import (
EmailAlreadyInUseError,
EmailChangeLimitError,
EmailCodeAccountDeletionRateLimitExceededError,
EmailCodeError,
InvalidEmailError,
InvalidTokenError,
@@ -42,6 +41,7 @@ from controllers.console.workspace.error import (
InvalidAccountDeletionCodeError,
InvalidAccountPasswordRequestError,
InvalidInvitationCodeError,
MissingInvitationCodeRequestError,
RepeatPasswordNotMatchError,
)
from controllers.console.wraps import (
@@ -52,17 +52,14 @@ from controllers.console.wraps import (
setup_required,
with_current_user,
)
from enums import DeploymentEdition
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.member_fields import AccountResponse
from libs.datetime_utils import naive_utc_now
from libs.helper import EmailStr, dump_response, extract_remote_ip, timezone, to_timestamp
from libs.login import login_required
from machinery.context import RequestContext
from models import Account, InvitationCode
from models.account import AccountStatus, InvitationCodeStatus
from models import Account
from services import account_errors
from services.account_service import AccountService
from services.billing_service import BillingService
@@ -304,44 +301,26 @@ def _update_account_profile(request_context: RequestContext, changes: AccountPro
class AccountInitApi(Resource):
@console_ns.expect(console_ns.models[AccountInitPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@with_current_user
def post(self, account: Account):
if account.status == "active":
raise AccountAlreadyInitedError()
@console_account_admission(require_initialized=False)
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountInitPayload.model_validate(payload)
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
if not args.invitation_code:
raise ValueError("invitation_code is required")
# check invitation code
invitation_code = db.session.scalar(
select(InvitationCode)
.where(
InvitationCode.code == args.invitation_code,
InvitationCode.status == InvitationCodeStatus.UNUSED,
)
.limit(1)
try:
application_services().accounts.initialization.initialize(
request_context,
interface_language=args.interface_language,
timezone=args.timezone,
invitation_code=args.invitation_code,
)
if not invitation_code:
raise InvalidInvitationCodeError()
invitation_code.status = InvitationCodeStatus.USED
invitation_code.used_at = naive_utc_now()
invitation_code.used_by_tenant_id = account.current_tenant_id
invitation_code.used_by_account_id = account.id
account.interface_language = args.interface_language
account.timezone = args.timezone
account.interface_theme = "light"
account.status = AccountStatus.ACTIVE
account.initialized_at = naive_utc_now()
db.session.commit()
except account_errors.AccountAlreadyInitializedError as error:
raise AccountAlreadyInitedError() from error
except account_errors.MissingInvitationCodeError as error:
raise MissingInvitationCodeRequestError() from error
except account_errors.InvalidInvitationCodeError as error:
raise InvalidInvitationCodeError() from error
except account_errors.AccountNotFoundError as error:
raise AccountNotFound() from error
return SimpleResultResponse(result="success").model_dump(mode="json")
@@ -511,14 +490,15 @@ class AccountIntegrateApi(Resource):
@console_ns.route("/account/delete/verify")
class AccountDeleteVerifyApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@with_current_user
def get(self, account: Account):
token, code = AccountService.generate_account_deletion_verification_code(account)
AccountService.send_account_deletion_verification_email(account, code)
@console_account_admission()
def get(self, request_context: RequestContext):
try:
token = application_services().accounts.deletion.issue_verification(request_context)
except account_errors.AccountDeletionRateLimitError as error:
raise EmailCodeAccountDeletionRateLimitExceededError(error.retry_after_minutes) from None
except account_errors.AccountNotFoundError:
raise AccountNotFound() from None
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@@ -527,18 +507,19 @@ class AccountDeleteVerifyApi(Resource):
class AccountDeleteApi(Resource):
@console_ns.expect(console_ns.models[AccountDeletePayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
def post(self, account: Account):
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountDeletePayload.model_validate(payload)
if not AccountService.verify_account_deletion_code(args.token, args.code):
raise InvalidAccountDeletionCodeError()
AccountService.delete_account(account, session=db.session())
try:
application_services().accounts.deletion.request_deletion(
request_context,
token=args.token,
code=args.code,
)
except account_errors.InvalidAccountDeletionVerificationError:
raise InvalidAccountDeletionCodeError() from None
return SimpleResultResponse(result="success").model_dump(mode="json")
@@ -31,6 +31,12 @@ class InvalidInvitationCodeError(BaseHTTPException):
code = 400
class MissingInvitationCodeRequestError(BaseHTTPException):
error_code = "missing_invitation_code"
description = "Invitation code is required."
code = 400
class AccountAlreadyInitedError(BaseHTTPException):
error_code = "account_already_inited"
description = "The account has been initialized. Please refresh the page."
+34 -3
View File
@@ -15,6 +15,8 @@ from core.db.session_factory import get_session_maker
from core.schemas.schema_manager import SchemaManager
from enums import DeploymentEdition, WebAppAccessMode
from extensions.ext_redis import RedisClientWrapper, redis_client
from libs.datetime_utils import naive_utc_now
from libs.helper import RateLimiter
from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository
from repositories.account_integration_repository import SQLAlchemyAccountIntegrationRepository
from repositories.account_repository import SQLAlchemyAccountRepository
@@ -38,6 +40,14 @@ from services.account_activation_adapters import (
from services.account_activation_service import AccountActivationService
from services.account_avatar_file_gateway import SQLAlchemyAccountAvatarFileGateway
from services.account_avatar_service import AccountAvatarService
from services.account_deletion_adapters import (
CeleryAccountDeletionScheduler,
CeleryAccountDeletionVerificationNotifier,
EnterpriseAccountDeletionSyncGateway,
TokenManagerAccountDeletionVerificationGateway,
)
from services.account_deletion_service import AccountDeletionService
from services.account_initialization_service import AccountInitializationService
from services.account_integration_service import AccountIntegrationService
from services.account_password_hasher import LegacyAccountPasswordHasher
from services.account_password_service import AccountPasswordService
@@ -101,6 +111,8 @@ def _is_user_allowed_to_access_webapp(user_id: str, app_id: str) -> bool:
@dataclass(frozen=True, slots=True)
class AccountServices:
avatar: AccountAvatarService
deletion: AccountDeletionService
initialization: AccountInitializationService
integrations: AccountIntegrationService
password: AccountPasswordService
profile: AccountProfileService
@@ -148,11 +160,32 @@ def build_application_services(
database=database_catalog,
builtin=builtin_catalog,
)
workspace_query_repository = WorkspaceQueryRepository(client=database_client)
return ApplicationServices(
accounts=AccountServices(
avatar=AccountAvatarService(
files=SQLAlchemyAccountAvatarFileGateway(session_factory=database_client),
),
deletion=AccountDeletionService(
accounts=accounts,
memberships=workspace_query_repository,
verification=TokenManagerAccountDeletionVerificationGateway(),
notifications=CeleryAccountDeletionVerificationNotifier(
rate_limiter=RateLimiter(
prefix="email_code_account_deletion_rate_limit",
max_attempts=1,
time_window=60,
redis_client=redis,
)
),
synchronization=EnterpriseAccountDeletionSyncGateway(),
scheduler=CeleryAccountDeletionScheduler(),
),
initialization=AccountInitializationService(
accounts=accounts,
invitation_required=deployment_edition == DeploymentEdition.CLOUD,
now=naive_utc_now,
),
integrations=AccountIntegrationService(integrations=integrations),
password=AccountPasswordService(
accounts=accounts,
@@ -222,9 +255,7 @@ def build_application_services(
),
trial_app_usage=TrialAppUsageRepository(session_factory=database_client),
workspace_queries=WorkspaceQueryService(
workspaces=WorkspaceQueryRepository(
client=database_client,
),
workspaces=workspace_query_repository,
plans=DeploymentWorkspacePlanGateway(),
),
workspace_member_queries=WorkspaceMemberQueryService(
+48 -1
View File
@@ -2,12 +2,16 @@
from typing import override
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from models.account import Account
from models.account import Account, AccountStatus, InvitationCode, InvitationCodeStatus
from services.account_ports import AccountRepository
from services.entities.account_entities import (
AccountCredentials,
AccountInitialization,
AccountInitializationResult,
AccountInitializationStatus,
AccountPasswordDigest,
AccountProfileChanges,
AccountSnapshot,
@@ -65,6 +69,49 @@ class SQLAlchemyAccountRepository(AccountRepository):
session.flush()
return self._to_snapshot(account)
@override
def initialize(
self,
account_id: str,
initialization: AccountInitialization,
*,
invitation_code: str | None,
workspace_id: str | None,
) -> AccountInitializationResult:
with self._session_factory.begin() as session:
account = session.get(Account, account_id)
if account is None:
return AccountInitializationResult(status=AccountInitializationStatus.ACCOUNT_NOT_FOUND)
if account.status == AccountStatus.ACTIVE:
return AccountInitializationResult(status=AccountInitializationStatus.ALREADY_INITIALIZED)
if invitation_code is not None:
invitation = session.scalar(
select(InvitationCode)
.where(
InvitationCode.code == invitation_code,
InvitationCode.status == InvitationCodeStatus.UNUSED,
)
.limit(1)
)
if invitation is None or workspace_id is None:
return AccountInitializationResult(status=AccountInitializationStatus.INVALID_INVITATION)
invitation.status = InvitationCodeStatus.USED
invitation.used_at = initialization.initialized_at
invitation.used_by_tenant_id = workspace_id
invitation.used_by_account_id = account_id
account.interface_language = initialization.interface_language
account.interface_theme = initialization.interface_theme
account.timezone = initialization.timezone
account.status = AccountStatus.ACTIVE
account.initialized_at = initialization.initialized_at
session.flush()
return AccountInitializationResult(
status=AccountInitializationStatus.INITIALIZED,
account=self._to_snapshot(account),
)
@staticmethod
def _to_snapshot(account: Account) -> AccountSnapshot:
return AccountSnapshot(
@@ -6,10 +6,11 @@ from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from models.account import Tenant, TenantAccountJoin, TenantStatus
from services.account_ports import AccountWorkspaceMembershipQuery
from services.workspace_query_service import WorkspaceQuery, WorkspaceRecord
class WorkspaceQueryRepository(WorkspaceQuery):
class WorkspaceQueryRepository(WorkspaceQuery, AccountWorkspaceMembershipQuery):
def __init__(self, client: sessionmaker[Session]) -> None:
self._client = client
@@ -43,3 +44,9 @@ class WorkspaceQueryRepository(WorkspaceQuery):
)
for workspace_id, name, status, created_at, last_opened_at in rows
)
@override
def list_ids_for_account(self, account_id: str) -> tuple[str, ...]:
stmt = select(TenantAccountJoin.tenant_id).where(TenantAccountJoin.account_id == account_id)
with self._client() as session:
return tuple(session.scalars(stmt).all())
+76
View File
@@ -0,0 +1,76 @@
"""Infrastructure adapters for the account deletion application service."""
import secrets
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, cast, override
from libs.helper import RateLimiter, TokenManager
from services.account_errors import AccountDeletionRateLimitError
from services.account_ports import (
AccountDeletionScheduler,
AccountDeletionSyncGateway,
AccountDeletionVerificationGateway,
AccountDeletionVerificationNotifier,
)
from services.enterprise.account_deletion_sync import sync_account_deletion_memberships
from services.entities.account_entities import AccountDeletionChallenge
from tasks.delete_account_task import delete_account_task
from tasks.mail_account_deletion_task import send_account_deletion_verification_code
if TYPE_CHECKING:
from models.account import Account
@dataclass(frozen=True, slots=True)
class _TokenAccount:
id: str
email: str
class TokenManagerAccountDeletionVerificationGateway(AccountDeletionVerificationGateway):
@override
def create(self, *, account_id: str, email: str) -> AccountDeletionChallenge:
code = "".join(str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6))
token = TokenManager.generate_token(
account=cast("Account", _TokenAccount(id=account_id, email=email)),
token_type="account_deletion",
additional_data={"code": code},
)
return AccountDeletionChallenge(token=token, code=code)
@override
def verify(self, *, account_id: str, token: str, code: str) -> bool:
token_data = TokenManager.get_token_data(token, "account_deletion")
if token_data is None:
return False
return token_data.get("account_id") == account_id and token_data.get("code") == code
class CeleryAccountDeletionVerificationNotifier(AccountDeletionVerificationNotifier):
def __init__(self, *, rate_limiter: RateLimiter) -> None:
self._rate_limiter = rate_limiter
@override
def send(self, *, email: str, code: str) -> None:
if self._rate_limiter.is_rate_limited(email):
raise AccountDeletionRateLimitError(int(self._rate_limiter.time_window / 60))
send_account_deletion_verification_code.delay(to=email, code=code)
self._rate_limiter.increment_rate_limit(email)
class EnterpriseAccountDeletionSyncGateway(AccountDeletionSyncGateway):
@override
def sync(self, *, account_id: str, workspace_ids: Sequence[str]) -> bool:
return sync_account_deletion_memberships(
account_id=account_id,
workspace_ids=workspace_ids,
source="account_deleted",
)
class CeleryAccountDeletionScheduler(AccountDeletionScheduler):
@override
def schedule(self, account_id: str) -> None:
delete_account_task.delay(account_id)
+56
View File
@@ -0,0 +1,56 @@
"""Application service for the current account deletion lifecycle."""
import logging
from machinery.context import RequestContext
from services.account_errors import AccountNotFoundError, InvalidAccountDeletionVerificationError
from services.account_ports import (
AccountDeletionScheduler,
AccountDeletionSyncGateway,
AccountDeletionVerificationGateway,
AccountDeletionVerificationNotifier,
AccountRepository,
AccountWorkspaceMembershipQuery,
)
logger = logging.getLogger(__name__)
class AccountDeletionService:
def __init__(
self,
*,
accounts: AccountRepository,
memberships: AccountWorkspaceMembershipQuery,
verification: AccountDeletionVerificationGateway,
notifications: AccountDeletionVerificationNotifier,
synchronization: AccountDeletionSyncGateway,
scheduler: AccountDeletionScheduler,
) -> None:
self._accounts = accounts
self._memberships = memberships
self._verification = verification
self._notifications = notifications
self._synchronization = synchronization
self._scheduler = scheduler
def issue_verification(self, context: RequestContext) -> str:
account = self._accounts.get(context.account_id)
if account is None:
raise AccountNotFoundError
challenge = self._verification.create(account_id=account.id, email=account.email)
self._notifications.send(email=account.email, code=challenge.code)
return challenge.token
def request_deletion(self, context: RequestContext, *, token: str, code: str) -> None:
if not self._verification.verify(account_id=context.account_id, token=token, code=code):
raise InvalidAccountDeletionVerificationError
workspace_ids = tuple(self._memberships.list_ids_for_account(context.account_id))
if not self._synchronization.sync(account_id=context.account_id, workspace_ids=workspace_ids):
logger.warning(
"Enterprise account deletion sync failed for account %s; proceeding with local deletion.",
context.account_id,
)
self._scheduler.schedule(context.account_id)
+24
View File
@@ -19,3 +19,27 @@ class InvalidAccountPasswordError(AccountApplicationError):
class AvatarFileNotFoundError(AccountApplicationError):
"""The requested avatar file does not exist or is not owned by the account."""
class AccountAlreadyInitializedError(AccountApplicationError):
"""The account is already active and cannot be initialized again."""
class MissingInvitationCodeError(AccountApplicationError):
"""Cloud account initialization requires an invitation code."""
class InvalidInvitationCodeError(AccountApplicationError):
"""The invitation code is missing, used, or otherwise invalid."""
class InvalidAccountDeletionVerificationError(AccountApplicationError):
"""The account deletion token or verification code is invalid."""
class AccountDeletionRateLimitError(AccountApplicationError):
"""Too many account deletion verification emails were requested."""
def __init__(self, retry_after_minutes: int) -> None:
super().__init__(retry_after_minutes)
self.retry_after_minutes = retry_after_minutes
@@ -0,0 +1,65 @@
"""Application service for initializing a newly admitted account."""
from collections.abc import Callable
from datetime import datetime
from machinery.context import RequestContext
from machinery.errors import ActiveWorkspaceRequiredError
from services.account_errors import (
AccountAlreadyInitializedError,
AccountNotFoundError,
InvalidInvitationCodeError,
MissingInvitationCodeError,
)
from services.account_ports import AccountRepository
from services.entities.account_entities import AccountInitialization, AccountInitializationStatus, AccountSnapshot
class AccountInitializationService:
def __init__(
self,
*,
accounts: AccountRepository,
invitation_required: bool,
now: Callable[[], datetime],
) -> None:
self._accounts = accounts
self._invitation_required = invitation_required
self._now = now
def initialize(
self,
context: RequestContext,
*,
interface_language: str,
timezone: str,
invitation_code: str | None,
) -> AccountSnapshot:
workspace_id: str | None = None
if self._invitation_required:
if invitation_code is None:
raise MissingInvitationCodeError("invitation_code is required")
workspace_id = context.active_workspace_id
if workspace_id is None:
raise ActiveWorkspaceRequiredError
result = self._accounts.initialize(
context.account_id,
AccountInitialization(
interface_language=interface_language,
interface_theme="light",
timezone=timezone,
initialized_at=self._now(),
),
invitation_code=invitation_code if self._invitation_required else None,
workspace_id=workspace_id,
)
if result.status == AccountInitializationStatus.ACCOUNT_NOT_FOUND:
raise AccountNotFoundError
if result.status == AccountInitializationStatus.ALREADY_INITIALIZED:
raise AccountAlreadyInitializedError
if result.status == AccountInitializationStatus.INVALID_INVITATION:
raise InvalidInvitationCodeError
if result.account is None:
raise RuntimeError("Account repository returned an initialized result without an account")
return result.account
+35
View File
@@ -1,9 +1,13 @@
"""Persistence ports used by account application services."""
from collections.abc import Sequence
from typing import Protocol
from services.entities.account_entities import (
AccountCredentials,
AccountDeletionChallenge,
AccountInitialization,
AccountInitializationResult,
AccountIntegrationSnapshot,
AccountPasswordDigest,
AccountProfileChanges,
@@ -20,11 +24,24 @@ class AccountRepository(Protocol):
def update_password(self, account_id: str, password: AccountPasswordDigest) -> AccountSnapshot | None: ...
def initialize(
self,
account_id: str,
initialization: AccountInitialization,
*,
invitation_code: str | None,
workspace_id: str | None,
) -> AccountInitializationResult: ...
class AccountIntegrationRepository(Protocol):
def list_for_account(self, account_id: str) -> list[AccountIntegrationSnapshot]: ...
class AccountWorkspaceMembershipQuery(Protocol):
def list_ids_for_account(self, account_id: str) -> Sequence[str]: ...
class AccountAvatarFileGateway(Protocol):
def get_owned_signed_url(self, *, account_id: str, upload_file_id: str) -> str | None: ...
@@ -33,3 +50,21 @@ class AccountPasswordHasher(Protocol):
def verify(self, password: str, *, password_hash: str, password_salt: str) -> bool: ...
def hash(self, password: str) -> AccountPasswordDigest: ...
class AccountDeletionVerificationGateway(Protocol):
def create(self, *, account_id: str, email: str) -> AccountDeletionChallenge: ...
def verify(self, *, account_id: str, token: str, code: str) -> bool: ...
class AccountDeletionVerificationNotifier(Protocol):
def send(self, *, email: str, code: str) -> None: ...
class AccountDeletionSyncGateway(Protocol):
def sync(self, *, account_id: str, workspace_ids: Sequence[str]) -> bool: ...
class AccountDeletionScheduler(Protocol):
def schedule(self, account_id: str) -> None: ...
+1 -112
View File
@@ -13,7 +13,7 @@ import uuid
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
from hashlib import sha256
from typing import Any, NotRequired, TypedDict, cast
from typing import Any, NotRequired, TypedDict
from pydantic import BaseModel, TypeAdapter, ValidationError
from sqlalchemy import Row, delete, func, select, update
@@ -75,14 +75,11 @@ from services.errors.account import (
RefreshTokenNotFoundError,
RoleAlreadyAssignedError,
SeatsLimitExceededError,
TenantNotFoundError,
)
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.feature_service import FeatureService
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.telemetry_service import CommunityTelemetryService
from tasks.delete_account_task import delete_account_task
from tasks.mail_account_deletion_task import send_account_deletion_verification_code
from tasks.mail_change_mail_task import (
send_change_mail_completed_notification_task,
send_change_mail_task,
@@ -147,18 +144,13 @@ ACCOUNT_LAST_ACTIVE_REFRESH_INTERVAL = timedelta(minutes=10)
class AccountService:
CHANGE_EMAIL_PHASE_OLD = ChangeEmailPhase.OLD_EMAIL
CHANGE_EMAIL_PHASE_OLD_VERIFIED = ChangeEmailPhase.OLD_EMAIL_VERIFIED
CHANGE_EMAIL_PHASE_NEW = ChangeEmailPhase.NEW_EMAIL
CHANGE_EMAIL_PHASE_NEW_VERIFIED = ChangeEmailPhase.NEW_EMAIL_VERIFIED
reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1)
email_register_rate_limiter = RateLimiter(prefix="email_register_rate_limit", max_attempts=1, time_window=60 * 1)
email_code_login_rate_limiter = RateLimiter(
prefix="email_code_login_rate_limit", max_attempts=3, time_window=300 * 1
)
email_code_account_deletion_rate_limiter = RateLimiter(
prefix="email_code_account_deletion_rate_limit", max_attempts=1, time_window=60 * 1
)
change_email_rate_limiter = RateLimiter(prefix="change_email_rate_limit", max_attempts=1, time_window=60 * 1)
owner_transfer_rate_limiter = RateLimiter(prefix="owner_transfer_rate_limit", max_attempts=1, time_window=60 * 1)
@@ -526,55 +518,6 @@ class AccountService:
return account
@staticmethod
def generate_account_deletion_verification_code(account: Account) -> tuple[str, str]:
code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)])
token = TokenManager.generate_token(
account=account, token_type="account_deletion", additional_data={"code": code}
)
return token, code
@classmethod
def send_account_deletion_verification_email(cls, account: Account, code: str):
email = account.email
if cls.email_code_account_deletion_rate_limiter.is_rate_limited(email):
from controllers.console.auth.error import EmailCodeAccountDeletionRateLimitExceededError
raise EmailCodeAccountDeletionRateLimitExceededError(
int(cls.email_code_account_deletion_rate_limiter.time_window / 60)
)
send_account_deletion_verification_code.delay(to=email, code=code)
cls.email_code_account_deletion_rate_limiter.increment_rate_limit(email)
@staticmethod
def verify_account_deletion_code(token: str, code: str) -> bool:
token_data = TokenManager.get_token_data(token, "account_deletion")
if token_data is None:
return False
if token_data["code"] != code:
return False
return True
@staticmethod
def delete_account(account: Account, *, session: Session):
"""Delete account. This method only adds a task to the queue for deletion."""
# Queue account deletion sync tasks for all workspaces BEFORE account deletion (enterprise only)
from services.enterprise.account_deletion_sync import sync_account_deletion
sync_success = sync_account_deletion(account_id=account.id, source="account_deleted", session=session)
if not sync_success:
logger.warning(
"Enterprise account deletion sync failed for account %s; proceeding with local deletion.",
account.id,
)
# Now proceed with async account deletion
delete_account_task.delay(account.id)
@staticmethod
def link_account_integrate(provider: str, open_id: str, account: Account, *, session: Session):
"""Link account integrate"""
@@ -604,25 +547,6 @@ class AccountService:
logger.exception("Failed to link %s account %s to Account %s", provider, open_id, account.id)
raise LinkAccountIntegrateError("Failed to link account.") from e
@staticmethod
def close_account(account: Account, *, session: Session):
"""Close account"""
account.status = AccountStatus.CLOSED
session.commit()
@staticmethod
def update_account(account: Account, *, session: Session, **kwargs):
"""Update account fields"""
account = session.merge(account)
for field, value in kwargs.items():
if hasattr(account, field):
setattr(account, field, value)
else:
raise AttributeError(f"Invalid field: {field}")
session.commit()
return account
@staticmethod
def update_account_email(account: Account, email: str, session: Session) -> Account:
"""Update account email"""
@@ -1036,18 +960,10 @@ class AccountService:
return session.execute(select(Account).where(Account.email == email.lower())).scalar_one_or_none()
@classmethod
def get_email_code_login_data(cls, token: str) -> dict[str, Any] | None:
return TokenManager.get_token_data(token, "email_code_login")
@classmethod
def verify_email_code_login_challenge(cls, *, email: str, code: str, token: str) -> EmailCodeLoginChallengeResult:
return EmailCodeLoginChallengeStore.verify(email=email, code=code, token=token)
@classmethod
def revoke_email_code_login_token(cls, token: str):
TokenManager.revoke_token(token, "email_code_login")
@classmethod
def get_user_through_email(cls, email: str, *, session: Session):
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email):
@@ -1070,10 +986,6 @@ class AccountService:
return account
@classmethod
def is_account_in_freeze(cls, email: str) -> bool:
return cls.get_account_freeze_type(email) is not None
@classmethod
def get_account_freeze_type(cls, email: str):
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
@@ -1547,24 +1459,6 @@ class TenantService:
)
).first()
@staticmethod
def get_current_tenant_by_account(account: Account, *, session: Session):
"""Get tenant by account and add the role"""
tenant = account.current_tenant
if not tenant:
raise TenantNotFoundError("Tenant not found.")
ta = session.scalar(
select(TenantAccountJoin)
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == account.id)
.limit(1)
)
if ta:
object.__setattr__(tenant, "role", ta.role)
else:
raise TenantNotFoundError("Tenant not found for the account.")
return tenant
@staticmethod
def switch_tenant(account: Account, tenant_id: str | None = None, *, session: Session):
"""Switch the current workspace for the account"""
@@ -1683,11 +1577,6 @@ class TenantService:
)
return TenantAccountRole(join.role) if join else None
@staticmethod
def get_tenant_count(*, session: Session) -> int:
"""Get tenant count"""
return cast(int, session.scalar(select(func.count(Tenant.id))))
@staticmethod
def check_member_permission(
tenant: Tenant, operator: Account, member: Account | None, action: str, *, session: Session
@@ -1,16 +1,14 @@
import json
import logging
import uuid
from collections.abc import Sequence
from datetime import UTC, datetime
from redis import RedisError
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from enums import DeploymentEdition
from extensions.ext_redis import redis_client
from models.account import TenantAccountJoin
logger = logging.getLogger(__name__)
@@ -88,31 +86,14 @@ def sync_workspace_member_removal(workspace_id: str, member_id: str, *, source:
return _queue_task(workspace_id=workspace_id, member_id=member_id, source=source)
def sync_account_deletion(account_id: str, *, source: str, session: Session) -> bool:
"""
Sync full account deletion across all workspaces (enterprise only).
Fetches all workspace memberships for the account and queues a sync task for each.
Handles enterprise edition check internally. Safe to call in community edition (no-op).
Args:
account_id: The account ID being deleted
source: Source of the sync request (e.g., "account_deleted")
session: SQLAlchemy session used to fetch workspace memberships
Returns:
bool: True if all tasks were queued (or skipped outside the Enterprise edition), False if any queueing failed
"""
def sync_account_deletion_memberships(account_id: str, workspace_ids: Sequence[str], *, source: str) -> bool:
"""Queue deletion synchronization after membership persistence has been read and closed."""
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return True
# Fetch all workspaces the account belongs to
workspace_joins = session.scalars(select(TenantAccountJoin).where(TenantAccountJoin.account_id == account_id)).all()
# Queue sync task for each workspace
success = True
for join in workspace_joins:
if not _queue_task(workspace_id=join.tenant_id, member_id=account_id, source=source):
for workspace_id in workspace_ids:
if not _queue_task(workspace_id=workspace_id, member_id=account_id, source=source):
success = False
return success
+28
View File
@@ -2,6 +2,7 @@
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
@dataclass(frozen=True, slots=True)
@@ -65,3 +66,30 @@ class AccountIntegrationStatus:
provider: str
created_at: datetime | None
is_bound: bool
@dataclass(frozen=True, slots=True)
class AccountInitialization:
interface_language: str
interface_theme: str
timezone: str
initialized_at: datetime
class AccountInitializationStatus(StrEnum):
INITIALIZED = "initialized"
ACCOUNT_NOT_FOUND = "account_not_found"
ALREADY_INITIALIZED = "already_initialized"
INVALID_INVITATION = "invalid_invitation"
@dataclass(frozen=True, slots=True)
class AccountInitializationResult:
status: AccountInitializationStatus
account: AccountSnapshot | None = None
@dataclass(frozen=True, slots=True)
class AccountDeletionChallenge:
token: str
code: str
@@ -18,7 +18,6 @@ from services.errors.account import (
AccountPasswordError,
AccountRegisterError,
SeatsLimitExceededError,
TenantNotFoundError,
)
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from tests.test_containers_integration_tests.helpers import generate_valid_password
@@ -489,91 +488,6 @@ class TestAccountService:
)
assert integration.open_id == "google_open_id_456"
def test_close_account(self, db_session_with_containers: Session, mock_external_service_dependencies):
"""
Test closing an account.
"""
fake = Faker()
email = fake.email()
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
account = AccountService.create_account(
email=email,
name=name,
interface_language="en-US",
password=password,
session=db_session_with_containers,
)
# Close account
AccountService.close_account(account, session=db_session_with_containers)
# Verify account status changed
db_session_with_containers.refresh(account)
assert account.status == AccountStatus.CLOSED
def test_update_account_fields(self, db_session_with_containers: Session, mock_external_service_dependencies):
"""
Test updating account fields.
"""
fake = Faker()
email = fake.email()
name = fake.name()
password = generate_valid_password(fake)
updated_name = fake.name()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
account = AccountService.create_account(
email=email,
name=name,
interface_language="en-US",
password=password,
session=db_session_with_containers,
)
# Update account fields
updated_account = AccountService.update_account(
account, name=updated_name, interface_theme="dark", session=db_session_with_containers
)
assert updated_account.name == updated_name
assert updated_account.interface_theme == "dark"
def test_update_account_invalid_field(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test updating account with invalid field.
"""
fake = Faker()
email = fake.email()
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
account = AccountService.create_account(
email=email,
name=name,
interface_language="en-US",
password=password,
session=db_session_with_containers,
)
with pytest.raises(AttributeError):
AccountService.update_account(account, invalid_field="value", session=db_session_with_containers)
def test_update_login_info(self, db_session_with_containers: Session, mock_external_service_dependencies):
"""
Test updating login information.
@@ -1024,148 +938,6 @@ class TestAccountService:
# Reset config
dify_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
def test_delete_account(self, db_session_with_containers: Session, mock_external_service_dependencies):
"""
Test account deletion (should add task to queue and sync to enterprise).
"""
fake = Faker()
email = fake.email()
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
account = AccountService.create_account(
email=email,
name=name,
interface_language="en-US",
password=password,
session=db_session_with_containers,
)
with (
patch("services.account_service.delete_account_task") as mock_delete_task,
patch("services.enterprise.account_deletion_sync.sync_account_deletion") as mock_sync,
):
mock_sync.return_value = True
# Delete account
AccountService.delete_account(account, session=db_session_with_containers)
# Verify sync was called
mock_sync.assert_called_once_with(
account_id=account.id, source="account_deleted", session=db_session_with_containers
)
# Verify task was added to queue
mock_delete_task.delay.assert_called_once_with(account.id)
def test_generate_account_deletion_verification_code(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test generating account deletion verification code.
"""
fake = Faker()
email = fake.email()
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
account = AccountService.create_account(
email=email,
name=name,
interface_language="en-US",
password=password,
session=db_session_with_containers,
)
# Generate verification code
token, code = AccountService.generate_account_deletion_verification_code(account)
assert token is not None
assert code is not None
assert len(code) == 6
assert code.isdigit()
def test_verify_account_deletion_code_valid(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test verifying valid account deletion code.
"""
fake = Faker()
email = fake.email()
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
account = AccountService.create_account(
email=email,
name=name,
interface_language="en-US",
password=password,
session=db_session_with_containers,
)
# Generate verification code
token, code = AccountService.generate_account_deletion_verification_code(account)
# Verify code
is_valid = AccountService.verify_account_deletion_code(token, code)
assert is_valid is True
def test_verify_account_deletion_code_invalid(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test verifying invalid account deletion code.
"""
fake = Faker()
email = fake.email()
name = fake.name()
password = generate_valid_password(fake)
wrong_code = fake.numerify(text="######")
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
account = AccountService.create_account(
email=email,
name=name,
interface_language="en-US",
password=password,
session=db_session_with_containers,
)
# Generate verification code
token, code = AccountService.generate_account_deletion_verification_code(account)
# Verify with wrong code
is_valid = AccountService.verify_account_deletion_code(token, wrong_code)
assert is_valid is False
def test_verify_account_deletion_code_invalid_token(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test verifying account deletion code with invalid token.
"""
fake = Faker()
invalid_token = fake.uuid4()
invalid_code = fake.numerify(text="######")
is_valid = AccountService.verify_account_deletion_code(invalid_token, invalid_code)
assert is_valid is False
class TestTenantService:
"""Integration tests for TenantService using testcontainers."""
@@ -1382,69 +1154,6 @@ class TestTenantService:
assert tenant1_name in tenant_names
assert tenant2_name in tenant_names
def test_get_current_tenant_by_account_success(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test getting current tenant by account successfully.
"""
fake = Faker()
email = fake.email()
name = fake.name()
password = generate_valid_password(fake)
tenant_name = fake.company()
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create account and tenant
account = AccountService.create_account(
email=email,
name=name,
interface_language="en-US",
password=password,
session=db_session_with_containers,
)
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
# Add account to tenant and set as current
TenantService.create_tenant_member(tenant, account, db_session_with_containers, role="owner")
account.current_tenant = tenant
db_session_with_containers.commit()
# Get current tenant
current_tenant = TenantService.get_current_tenant_by_account(account, session=db_session_with_containers)
assert current_tenant.id == tenant.id
assert current_tenant.name == tenant.name
assert current_tenant.role == "owner"
def test_get_current_tenant_by_account_not_found(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test getting current tenant when account has no current tenant.
"""
fake = Faker()
email = fake.email()
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create account without setting current tenant
account = AccountService.create_account(
email=email,
name=name,
interface_language="en-US",
password=password,
session=db_session_with_containers,
)
# Try to get current tenant (should fail)
with pytest.raises((AttributeError, TenantNotFoundError)):
TenantService.get_current_tenant_by_account(account, session=db_session_with_containers)
def test_switch_tenant_success(self, db_session_with_containers: Session, mock_external_service_dependencies):
"""
Test successful tenant switching.
@@ -2061,28 +1770,6 @@ class TestTenantService:
tenant, member_account, "admin", owner_account, session=db_session_with_containers
)
def test_get_tenant_count_success(self, db_session_with_containers: Session, mock_external_service_dependencies):
"""
Test getting tenant count successfully.
"""
fake = Faker()
tenant1_name = fake.company()
tenant2_name = fake.company()
tenant3_name = fake.company()
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create multiple tenants
tenant1 = TenantService.create_tenant(name=tenant1_name, session=db_session_with_containers)
tenant2 = TenantService.create_tenant(name=tenant2_name, session=db_session_with_containers)
tenant3 = TenantService.create_tenant(name=tenant3_name, session=db_session_with_containers)
# Get tenant count
tenant_count = TenantService.get_tenant_count(session=db_session_with_containers)
# Should have at least 3 tenants (may be more from other tests)
assert tenant_count >= 3
def test_create_owner_tenant_if_not_exist_new_user(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
@@ -437,7 +437,7 @@ class TestOAuthCallback:
Context:
- AccountStatus.CLOSED is defined in the enum but never used in production
- The close_account() method exists but is never called
- No production service path sets accounts to CLOSED
- Account deletion uses external service instead of status change
- All authentication services (OAuth, password, email) don't check CLOSED status
@@ -25,6 +25,7 @@ from services.entities.auth_entities import (
ChangeEmailNewEmailVerifiedToken,
ChangeEmailOldEmailToken,
ChangeEmailOldEmailVerifiedToken,
ChangeEmailPhase,
)
@@ -99,11 +100,11 @@ def _build_change_email_token(
}
if phase == AccountService.CHANGE_EMAIL_PHASE_OLD:
return ChangeEmailOldEmailToken(**token_kwargs)
if phase == AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED:
if phase == ChangeEmailPhase.OLD_EMAIL_VERIFIED:
return ChangeEmailOldEmailVerifiedToken(**token_kwargs)
if phase == AccountService.CHANGE_EMAIL_PHASE_NEW:
return ChangeEmailNewEmailToken(**token_kwargs)
if phase == AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED:
if phase == ChangeEmailPhase.NEW_EMAIL_VERIFIED:
return ChangeEmailNewEmailVerifiedToken(**token_kwargs)
raise AssertionError(f"Unsupported phase for test helper: {phase}")
@@ -167,7 +168,7 @@ class TestChangeEmailSend:
):
mock_account = _build_account("current@example.com", "acc1")
mock_get_change_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
ChangeEmailPhase.OLD_EMAIL_VERIFIED,
account_id="acc1",
email="current@example.com",
old_email="current@example.com",
@@ -245,7 +246,7 @@ class TestChangeEmailSend:
mock_account = _build_account("current@example.com", "acc1")
mock_get_change_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
ChangeEmailPhase.OLD_EMAIL_VERIFIED,
account_id="other-account",
email="current@example.com",
old_email="current@example.com",
@@ -306,7 +307,7 @@ class TestChangeEmailValidity:
mock_revoke_token.assert_called_once_with("token-123")
mock_generate_token.assert_called_once_with(
_build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
ChangeEmailPhase.OLD_EMAIL_VERIFIED,
account_id="acc2",
email="user@example.com",
old_email="user@example.com",
@@ -353,7 +354,7 @@ class TestChangeEmailValidity:
assert response == {"is_valid": True, "email": "new@example.com", "token": "new-verified-token"}
mock_generate_token.assert_called_once_with(
_build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
ChangeEmailPhase.NEW_EMAIL_VERIFIED,
account_id="acc",
email="new@example.com",
old_email="old@example.com",
@@ -382,7 +383,7 @@ class TestChangeEmailValidity:
current_user = _build_account("old@example.com", "acc")
mock_is_rate_limit.return_value = False
mock_get_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
ChangeEmailPhase.OLD_EMAIL_VERIFIED,
account_id="acc",
email="old@example.com",
old_email="old@example.com",
@@ -497,7 +498,7 @@ class TestChangeEmailReset:
database_session.add(account_integration)
database_session.commit()
mock_get_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
ChangeEmailPhase.NEW_EMAIL_VERIFIED,
account_id=current_user.id,
email="new@example.com",
old_email="OLD@example.com",
@@ -588,7 +589,7 @@ class TestChangeEmailReset:
mock_is_freeze.return_value = False
mock_check_unique.return_value = True
mock_get_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
ChangeEmailPhase.NEW_EMAIL_VERIFIED,
account_id="acc3",
email="verified@example.com",
old_email="old@example.com",
@@ -630,7 +631,7 @@ class TestChangeEmailReset:
mock_is_freeze.return_value = False
mock_check_unique.return_value = True
mock_get_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
ChangeEmailPhase.NEW_EMAIL_VERIFIED,
account_id="other-account",
email="new@example.com",
old_email="old@example.com",
@@ -713,13 +714,13 @@ class TestAccountServiceGetChangeEmailData:
"email": "new@example.com",
"old_email": "old@example.com",
"code": "654321",
"email_change_phase": AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
"email_change_phase": ChangeEmailPhase.NEW_EMAIL_VERIFIED,
}
token_data = AccountService.get_change_email_data("token-123")
assert token_data == _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
ChangeEmailPhase.NEW_EMAIL_VERIFIED,
account_id="acc-1",
email="new@example.com",
old_email="old@example.com",
@@ -129,6 +129,19 @@ class TestAccountInitialization:
class TestCurrentContextInjection:
"""Test request context injection decorators."""
def test_console_maps_missing_active_workspace_to_safe_internal_error(self):
handler = console_api.error_handlers[ActiveWorkspaceRequiredError]
with Flask(__name__).app_context():
body, status = handler(ActiveWorkspaceRequiredError())
assert status == 500
assert body == {
"code": "active_workspace_required",
"message": "Internal Server Error",
"status": 500,
}
def test_console_account_admission_injects_request_context(self):
current_user = make_account()
@@ -266,18 +279,32 @@ class TestCurrentContextInjection:
with pytest.raises(AdmissionConfigurationError, match="configured together"):
flask_admission.console_account_admission(rbac_resource_scope=RBACResourceScope.WORKSPACE)
def test_console_maps_missing_active_workspace_to_safe_internal_error(self):
handler = console_api.error_handlers[ActiveWorkspaceRequiredError]
def test_console_account_admission_can_admit_uninitialized_accounts(self):
current_user = make_account()
with Flask(__name__).app_context():
body, status = handler(ActiveWorkspaceRequiredError())
with (
patch("controllers.console.flask_admission.setup_required", side_effect=lambda view: view),
patch("controllers.console.flask_admission.login_required", side_effect=lambda view: view),
patch(
"controllers.console.flask_admission.account_initialization_required",
side_effect=lambda view: view,
) as account_initialization_required,
patch(
"controllers.console.flask_admission.current_account_with_tenant",
return_value=AccountWithTenant(account=current_user, tenant_id="tenant-123"),
),
):
assert status == 500
assert body == {
"code": "active_workspace_required",
"message": "Internal Server Error",
"status": 500,
}
class Handler:
@flask_admission.console_account_admission(require_initialized=False)
def post(self, request_context: RequestContext):
return request_context
with Flask(__name__).test_request_context():
result = Handler().post()
assert result.account_id == current_user.id
account_initialization_required.assert_not_called()
def test_with_current_tenant_id_injects_tenant_id(self):
class Handler:
@@ -13,6 +13,7 @@ from werkzeug.exceptions import NotFound, UnprocessableEntity
from controllers.console import console_ns
from controllers.console.auth.error import (
EmailAlreadyInUseError,
EmailCodeAccountDeletionRateLimitExceededError,
EmailCodeError,
)
from controllers.console.error import AccountInFreezeError, EmailDomainSuspendedError
@@ -39,15 +40,19 @@ from controllers.console.workspace.error import (
CurrentPasswordIncorrectError,
InvalidAccountDeletionCodeError,
InvalidAccountPasswordRequestError,
MissingInvitationCodeRequestError,
)
from enums import DeploymentEdition
from machinery.context import RequestContext
from models import Account, InvitationCode, Tenant, TenantAccountJoin
from models.account import AccountStatus, InvitationCodeStatus, TenantAccountRole
from models import Account, Tenant, TenantAccountJoin
from models.account import AccountStatus, TenantAccountRole
from services.account_errors import (
AccountAlreadyInitializedError,
AccountDeletionRateLimitError,
AvatarFileNotFoundError,
CurrentAccountPasswordIncorrectError,
InvalidAccountDeletionVerificationError,
InvalidAccountPasswordError,
MissingInvitationCodeError,
)
from services.entities.account_entities import AccountIntegrationStatus, AccountProfileChanges
@@ -87,22 +92,16 @@ def persist_account_with_tenant(
class TestAccountInitApi:
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin, InvitationCode)],
indirect=True,
)
def test_init_success(self, app: Flask, sqlite_session: Session):
def test_init_success(self, app: Flask):
api = AccountInitApi()
method = inspect.unwrap(api.post)
account, tenant = persist_account_with_tenant(
sqlite_session,
status=AccountStatus.UNINITIALIZED,
request_context = RequestContext(
request_id="request-1",
trace_id=None,
account_id="account-1",
active_workspace_id="workspace-1",
)
invitation_code = InvitationCode(batch="batch-1", code="code123")
sqlite_session.add(invitation_code)
sqlite_session.commit()
initialization = MagicMock()
payload = {
"interface_language": "en-US",
"timezone": "UTC",
@@ -111,34 +110,73 @@ class TestAccountInitApi:
with (
app.test_request_context("/account/init", json=payload),
patch("controllers.console.workspace.account.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
patch("controllers.console.workspace.account.db.session", sqlite_session),
patch(
"controllers.console.workspace.account.application_services",
return_value=SimpleNamespace(accounts=SimpleNamespace(initialization=initialization)),
),
):
resp = method(api, account)
resp = method(api, request_context)
assert resp["result"] == "success"
sqlite_session.expire_all()
persisted_account = sqlite_session.get(Account, account.id)
persisted_invitation = sqlite_session.get(InvitationCode, invitation_code.id)
assert persisted_account is not None
assert persisted_account.status == AccountStatus.ACTIVE
assert persisted_account.interface_language == "en-US"
assert persisted_account.timezone == "UTC"
assert persisted_account.initialized_at is not None
assert persisted_invitation is not None
assert persisted_invitation.status == InvitationCodeStatus.USED
assert persisted_invitation.used_by_account_id == account.id
assert persisted_invitation.used_by_tenant_id == tenant.id
initialization.initialize.assert_called_once_with(
request_context,
interface_language="en-US",
timezone="UTC",
invitation_code="code123",
)
def test_init_already_initialized(self, app: Flask):
api = AccountInitApi()
method = inspect.unwrap(api.post)
account = make_account()
request_context = RequestContext(
request_id="request-1",
trace_id=None,
account_id="account-1",
active_workspace_id="workspace-1",
)
initialization = MagicMock()
initialization.initialize.side_effect = AccountAlreadyInitializedError
payload = {"interface_language": "en-US", "timezone": "UTC"}
with app.test_request_context("/account/init"):
with (
app.test_request_context("/account/init", json=payload),
patch(
"controllers.console.workspace.account.application_services",
return_value=SimpleNamespace(accounts=SimpleNamespace(initialization=initialization)),
),
):
with pytest.raises(AccountAlreadyInitedError):
method(api, account)
method(api, request_context)
def test_init_missing_invitation_code_is_mapped(self, app: Flask):
api = AccountInitApi()
method = inspect.unwrap(api.post)
request_context = RequestContext(
request_id="request-1",
trace_id=None,
account_id="account-1",
active_workspace_id="workspace-1",
)
initialization = MagicMock()
initialization.initialize.side_effect = MissingInvitationCodeError("invitation_code is required")
payload = {"interface_language": "en-US", "timezone": "UTC"}
with (
app.test_request_context("/account/init", json=payload),
patch(
"controllers.console.workspace.account.application_services",
return_value=SimpleNamespace(accounts=SimpleNamespace(initialization=initialization)),
),
):
with pytest.raises(MissingInvitationCodeRequestError) as exc_info:
method(api, request_context)
assert exc_info.value.data == {
"code": "missing_invitation_code",
"message": "Invitation code is required.",
"status": 400,
}
class TestAccountProfileApi:
@@ -523,39 +561,97 @@ class TestAccountDeleteApi:
def test_delete_verify_success(self, app: Flask):
api = AccountDeleteVerifyApi()
method = inspect.unwrap(api.get)
user = make_account()
request_context = RequestContext(
request_id="request-1",
trace_id=None,
account_id="account-1",
active_workspace_id="workspace-1",
)
deletion = MagicMock()
deletion.issue_verification.return_value = "token"
with (
app.test_request_context("/"),
patch(
"controllers.console.workspace.account.AccountService.generate_account_deletion_verification_code",
return_value=("token", "1234"),
),
patch(
"controllers.console.workspace.account.AccountService.send_account_deletion_verification_email",
return_value=None,
"controllers.console.workspace.account.application_services",
return_value=SimpleNamespace(accounts=SimpleNamespace(deletion=deletion)),
),
):
result = method(api, user)
result = method(api, request_context)
assert result["result"] == "success"
assert result["data"] == "token"
deletion.issue_verification.assert_called_once_with(request_context)
def test_delete_invalid_code(self, app: Flask):
api = AccountDeleteApi()
method = inspect.unwrap(api.post)
payload = {"token": "t", "code": "x"}
user = make_account()
request_context = RequestContext(
request_id="request-1",
trace_id=None,
account_id="account-1",
active_workspace_id="workspace-1",
)
deletion = MagicMock()
deletion.request_deletion.side_effect = InvalidAccountDeletionVerificationError
with (
app.test_request_context("/", json=payload),
patch(
"controllers.console.workspace.account.AccountService.verify_account_deletion_code",
return_value=False,
"controllers.console.workspace.account.application_services",
return_value=SimpleNamespace(accounts=SimpleNamespace(deletion=deletion)),
),
):
with pytest.raises(InvalidAccountDeletionCodeError):
method(api, user)
method(api, request_context)
def test_delete_verify_maps_rate_limit(self, app: Flask):
api = AccountDeleteVerifyApi()
method = inspect.unwrap(api.get)
request_context = RequestContext(
request_id="request-1",
trace_id=None,
account_id="account-1",
active_workspace_id="workspace-1",
)
deletion = MagicMock()
deletion.issue_verification.side_effect = AccountDeletionRateLimitError(1)
with (
app.test_request_context("/"),
patch(
"controllers.console.workspace.account.application_services",
return_value=SimpleNamespace(accounts=SimpleNamespace(deletion=deletion)),
),
pytest.raises(EmailCodeAccountDeletionRateLimitExceededError),
):
method(api, request_context)
def test_delete_success(self, app: Flask):
api = AccountDeleteApi()
method = inspect.unwrap(api.post)
request_context = RequestContext(
request_id="request-1",
trace_id=None,
account_id="account-1",
active_workspace_id="workspace-1",
)
deletion = MagicMock()
payload = {"token": "token", "code": "123456"}
with (
app.test_request_context("/", json=payload),
patch(
"controllers.console.workspace.account.application_services",
return_value=SimpleNamespace(accounts=SimpleNamespace(deletion=deletion)),
),
):
result = method(api, request_context)
assert result["result"] == "success"
deletion.request_deletion.assert_called_once_with(request_context, token="token", code="123456")
class TestChangeEmailApis:
@@ -206,7 +206,9 @@ class TestWorkspaceQueryRepository:
)
workspace_session.commit()
result = WorkspaceQueryRepository(workspace_session.session_factory).list_for_account("account-1")
repository = WorkspaceQueryRepository(workspace_session.session_factory)
result = repository.list_for_account("account-1")
membership_ids = repository.list_ids_for_account("account-1")
assert result == (
WorkspaceRecord(
@@ -224,6 +226,7 @@ class TestWorkspaceQueryRepository:
last_opened_at=None,
),
)
assert set(membership_ids) == {earlier.id, later.id, archived.id}
class TestDeploymentWorkspacePlanGateway:
@@ -185,6 +185,10 @@ def test_build_application_services_wires_account_profile_repository(
assert isinstance(accounts, SQLAlchemyAccountRepository)
assert accounts._session_factory is sqlite_session_factory
assert services.accounts.password._accounts is accounts
assert services.accounts.initialization._accounts is accounts
assert not services.accounts.initialization._invitation_required
assert services.accounts.deletion._accounts is accounts
assert services.accounts.deletion._memberships is services.workspace_queries._workspaces
integrations = services.accounts.integrations._integrations
assert isinstance(integrations, SQLAlchemyAccountIntegrationRepository)
assert integrations._session_factory is sqlite_session_factory
@@ -193,6 +197,19 @@ def test_build_application_services_wires_account_profile_repository(
assert avatar_files._session_factory is sqlite_session_factory
def test_build_application_services_requires_invitation_for_cloud_initialization(
sqlite_session_factory: sessionmaker[Session],
) -> None:
services = ext_application_services.build_application_services(
database_client=sqlite_session_factory,
deployment_edition=DeploymentEdition.CLOUD,
initialization_password="",
redis=MagicMock(spec=RedisClientWrapper),
)
assert services.accounts.initialization._invitation_required
@pytest.mark.parametrize(
("deployment_edition", "billing_enabled"),
[
@@ -29,5 +29,10 @@ def test_admission_configuration_error_uses_stable_default_message() -> None:
def test_active_workspace_required_error_has_stable_contract() -> None:
error = ActiveWorkspaceRequiredError()
assert isinstance(error, MachineryError)
assert not isinstance(error, ValueError)
assert error.error_code == "active_workspace_required"
assert error.message == "Admission did not resolve an active workspace."
assert str(error) == error.message
assert error.details == ()
assert not hasattr(error, "code")
@@ -1,10 +1,12 @@
from datetime import datetime
import pytest
from sqlalchemy.orm import Session, sessionmaker
from models.account import Account, AccountIntegrate
from models.account import Account, AccountIntegrate, AccountStatus, InvitationCode, InvitationCodeStatus
from repositories.account_integration_repository import SQLAlchemyAccountIntegrationRepository
from repositories.account_repository import SQLAlchemyAccountRepository
from services.entities.account_entities import AccountPasswordDigest, AccountProfileChanges
from services.entities.account_entities import AccountInitialization, AccountPasswordDigest, AccountProfileChanges
def _persist_account(session: Session) -> Account:
@@ -115,3 +117,43 @@ def test_account_integration_repository_lists_integrations(
assert len(integrations) == 1
assert integrations[0].provider == "github"
def test_account_repository_initializes_account_and_consumes_invitation_atomically(
sqlite_session: Session,
sqlite_session_factory: sessionmaker[Session],
) -> None:
account = _persist_account(sqlite_session)
account.status = AccountStatus.UNINITIALIZED
invitation = InvitationCode(batch="batch-1", code="invite-1")
sqlite_session.add_all([account, invitation])
sqlite_session.commit()
initialized_at = datetime(2026, 8, 10, 12, 0)
repository = SQLAlchemyAccountRepository(sqlite_session_factory)
result = repository.initialize(
"account-1",
AccountInitialization(
interface_language="zh-Hans",
interface_theme="light",
timezone="Asia/Shanghai",
initialized_at=initialized_at,
),
invitation_code="invite-1",
workspace_id="workspace-1",
)
assert result.account is not None
assert result.account.status == "active"
sqlite_session.expire_all()
persisted_account = sqlite_session.get(Account, "account-1")
persisted_invitation = sqlite_session.get(InvitationCode, invitation.id)
assert persisted_account is not None
assert persisted_account.status == AccountStatus.ACTIVE
assert persisted_account.interface_language == "zh-Hans"
assert persisted_account.timezone == "Asia/Shanghai"
assert persisted_account.initialized_at == initialized_at
assert persisted_invitation is not None
assert persisted_invitation.status == InvitationCodeStatus.USED
assert persisted_invitation.used_by_account_id == "account-1"
assert persisted_invitation.used_by_tenant_id == "workspace-1"
@@ -1,4 +1,4 @@
"""Unit tests for account deletion synchronization with SQLite memberships.
"""Unit tests for account deletion synchronization.
Verifies enterprise account deletion sync functionality including
Redis queuing, error handling, and community vs enterprise behavior.
@@ -11,13 +11,11 @@ from uuid import uuid4
import pytest
from redis import RedisError
from sqlalchemy.orm import Session
from enums import DeploymentEdition
from models.account import TenantAccountJoin
from services.enterprise.account_deletion_sync import (
_queue_task,
sync_account_deletion,
sync_account_deletion_memberships,
sync_workspace_member_removal,
)
@@ -78,78 +76,18 @@ class TestSyncWorkspaceMemberRemoval:
assert result is False
@pytest.mark.parametrize("sqlite_session", [(TenantAccountJoin,)], indirect=True)
class TestSyncAccountDeletion:
@pytest.fixture
def mock_queue_task(self):
with patch("services.enterprise.account_deletion_sync._queue_task") as mock_queue:
mock_queue.return_value = True
yield mock_queue
def test_sync_account_deletion_memberships_queues_preloaded_workspace_ids() -> None:
with (
patch("services.enterprise.account_deletion_sync.dify_config") as mock_config,
patch("services.enterprise.account_deletion_sync._queue_task", return_value=True) as queue_task,
):
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
def test_sync_account_deletion_non_enterprise_edition(
self, mock_queue_task, sqlite_session: Session, config_overrides
) -> None:
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY)
result = sync_account_deletion_memberships(
account_id="account-1",
workspace_ids=("workspace-1", "workspace-2"),
source="account_deleted",
)
result = sync_account_deletion(account_id=str(uuid4()), source="account_deleted", session=sqlite_session)
assert result is True
mock_queue_task.assert_not_called()
def test_sync_account_deletion_multiple_workspaces(self, sqlite_session: Session, mock_queue_task) -> None:
account_id = str(uuid4())
tenant_ids = [str(uuid4()) for _ in range(3)]
for tenant_id in tenant_ids:
join = TenantAccountJoin(tenant_id=tenant_id, account_id=account_id)
sqlite_session.add(join)
sqlite_session.commit()
result = sync_account_deletion(account_id=account_id, source="account_deleted", session=sqlite_session)
assert result is True
assert mock_queue_task.call_count == 3
queued_workspace_ids = {call.kwargs["workspace_id"] for call in mock_queue_task.call_args_list}
assert queued_workspace_ids == set(tenant_ids)
def test_sync_account_deletion_no_workspaces(self, sqlite_session: Session, mock_queue_task) -> None:
result = sync_account_deletion(account_id=str(uuid4()), source="account_deleted", session=sqlite_session)
assert result is True
mock_queue_task.assert_not_called()
def test_sync_account_deletion_partial_failure(self, sqlite_session: Session, mock_queue_task) -> None:
account_id = str(uuid4())
tenant_ids = [str(uuid4()) for _ in range(3)]
fail_tenant = tenant_ids[1]
for tenant_id in tenant_ids:
join = TenantAccountJoin(tenant_id=tenant_id, account_id=account_id)
sqlite_session.add(join)
sqlite_session.commit()
def queue_side_effect(workspace_id, member_id, source):
return workspace_id != fail_tenant
mock_queue_task.side_effect = queue_side_effect
result = sync_account_deletion(account_id=account_id, source="account_deleted", session=sqlite_session)
assert result is False
assert mock_queue_task.call_count == 3
def test_sync_account_deletion_all_failures(self, sqlite_session: Session, mock_queue_task) -> None:
account_id = str(uuid4())
tenant_id = str(uuid4())
join = TenantAccountJoin(tenant_id=tenant_id, account_id=account_id)
sqlite_session.add(join)
sqlite_session.commit()
mock_queue_task.return_value = False
result = sync_account_deletion(account_id=account_id, source="account_deleted", session=sqlite_session)
assert result is False
mock_queue_task.assert_called_once()
assert result is True
assert [call.kwargs["workspace_id"] for call in queue_task.call_args_list] == ["workspace-1", "workspace-2"]
@@ -0,0 +1,57 @@
from unittest.mock import MagicMock, patch
import pytest
from libs.helper import RateLimiter
from services.account_deletion_adapters import (
CeleryAccountDeletionVerificationNotifier,
TokenManagerAccountDeletionVerificationGateway,
)
from services.account_errors import AccountDeletionRateLimitError
def test_verification_gateway_binds_token_to_the_target_account() -> None:
gateway = TokenManagerAccountDeletionVerificationGateway()
with patch(
"services.account_deletion_adapters.TokenManager.get_token_data",
return_value={"account_id": "account-1", "code": "123456"},
):
assert gateway.verify(account_id="account-1", token="token", code="123456") is True
assert gateway.verify(account_id="account-2", token="token", code="123456") is False
def test_verification_gateway_creates_six_digit_account_bound_challenge() -> None:
gateway = TokenManagerAccountDeletionVerificationGateway()
with (
patch("services.account_deletion_adapters.secrets.randbelow", side_effect=[1, 2, 3, 4, 5, 6]),
patch(
"services.account_deletion_adapters.TokenManager.generate_token",
return_value="token",
) as generate_token,
):
challenge = gateway.create(account_id="account-1", email="account@example.com")
assert challenge.token == "token"
assert challenge.code == "123456"
token_account = generate_token.call_args.kwargs["account"]
assert token_account.id == "account-1"
assert token_account.email == "account@example.com"
assert generate_token.call_args.kwargs["additional_data"] == {"code": "123456"}
def test_verification_notifier_preserves_rate_limit_before_enqueuing_email() -> None:
limiter = MagicMock(spec=RateLimiter)
limiter.is_rate_limited.return_value = True
limiter.time_window = 60
notifier = CeleryAccountDeletionVerificationNotifier(rate_limiter=limiter)
with (
patch("services.account_deletion_adapters.send_account_deletion_verification_code") as mail_task,
pytest.raises(AccountDeletionRateLimitError) as error,
):
notifier.send(email="account@example.com", code="123456")
assert error.value.retry_after_minutes == 1
mail_task.delay.assert_not_called()
@@ -0,0 +1,118 @@
from __future__ import annotations
from datetime import datetime
from unittest.mock import Mock, call
import pytest
from machinery.context import RequestContext
from services.account_deletion_service import AccountDeletionService
from services.account_errors import InvalidAccountDeletionVerificationError
from services.account_ports import (
AccountDeletionScheduler,
AccountDeletionSyncGateway,
AccountDeletionVerificationGateway,
AccountDeletionVerificationNotifier,
AccountRepository,
AccountWorkspaceMembershipQuery,
)
from services.entities.account_entities import AccountDeletionChallenge, AccountSnapshot
def _context() -> RequestContext:
return RequestContext(
request_id="request-1",
trace_id="trace-1",
account_id="account-1",
active_workspace_id="workspace-1",
)
def _account() -> AccountSnapshot:
return AccountSnapshot(
id="account-1",
name="Account",
email="account@example.com",
avatar=None,
is_password_set=True,
interface_language="en-US",
interface_theme="light",
timezone="UTC",
last_login_at=None,
last_login_ip=None,
status="active",
initialized_at=datetime(2026, 1, 1),
created_at=datetime(2026, 1, 1),
)
def _service(*, accounts: Mock | None = None) -> tuple[AccountDeletionService, dict[str, Mock]]:
dependencies = {
"accounts": accounts or Mock(spec=AccountRepository),
"memberships": Mock(spec=AccountWorkspaceMembershipQuery),
"verification": Mock(spec=AccountDeletionVerificationGateway),
"notifications": Mock(spec=AccountDeletionVerificationNotifier),
"synchronization": Mock(spec=AccountDeletionSyncGateway),
"scheduler": Mock(spec=AccountDeletionScheduler),
}
service = AccountDeletionService(
accounts=dependencies["accounts"],
memberships=dependencies["memberships"],
verification=dependencies["verification"],
notifications=dependencies["notifications"],
synchronization=dependencies["synchronization"],
scheduler=dependencies["scheduler"],
)
return service, dependencies
def test_issue_verification_reads_account_then_sends_challenge() -> None:
accounts = Mock(spec=AccountRepository)
accounts.get.return_value = _account()
service, dependencies = _service(accounts=accounts)
dependencies["verification"].create.return_value = AccountDeletionChallenge(token="token", code="123456")
token = service.issue_verification(_context())
assert token == "token"
accounts.get.assert_called_once_with("account-1")
dependencies["verification"].create.assert_called_once_with(
account_id="account-1",
email="account@example.com",
)
dependencies["notifications"].send.assert_called_once_with(email="account@example.com", code="123456")
def test_request_deletion_rejects_invalid_or_cross_account_verification_before_membership_read() -> None:
service, dependencies = _service()
dependencies["verification"].verify.return_value = False
with pytest.raises(InvalidAccountDeletionVerificationError):
service.request_deletion(_context(), token="token", code="wrong")
dependencies["memberships"].list_ids_for_account.assert_not_called()
dependencies["scheduler"].schedule.assert_not_called()
def test_request_deletion_reads_memberships_before_external_sync_and_always_schedules() -> None:
service, dependencies = _service()
dependencies["verification"].verify.return_value = True
dependencies["memberships"].list_ids_for_account.return_value = ("workspace-1", "workspace-2")
dependencies["synchronization"].sync.return_value = False
manager = Mock()
manager.attach_mock(dependencies["memberships"], "memberships")
manager.attach_mock(dependencies["synchronization"], "synchronization")
manager.attach_mock(dependencies["scheduler"], "scheduler")
service.request_deletion(_context(), token="token", code="123456")
dependencies["verification"].verify.assert_called_once_with(
account_id="account-1",
token="token",
code="123456",
)
assert manager.mock_calls == [
call.memberships.list_ids_for_account("account-1"),
call.synchronization.sync(account_id="account-1", workspace_ids=("workspace-1", "workspace-2")),
call.scheduler.schedule("account-1"),
]
@@ -0,0 +1,138 @@
from __future__ import annotations
from datetime import datetime
from unittest.mock import Mock
import pytest
from machinery.context import RequestContext
from machinery.errors import ActiveWorkspaceRequiredError
from services.account_errors import (
AccountAlreadyInitializedError,
InvalidInvitationCodeError,
MissingInvitationCodeError,
)
from services.account_initialization_service import AccountInitializationService
from services.account_ports import AccountRepository
from services.entities.account_entities import (
AccountInitialization,
AccountInitializationResult,
AccountInitializationStatus,
AccountSnapshot,
)
def _context() -> RequestContext:
return RequestContext(
request_id="request-1",
trace_id="trace-1",
account_id="account-1",
active_workspace_id="workspace-1",
)
def _account(*, status: str = "uninitialized") -> AccountSnapshot:
return AccountSnapshot(
id="account-1",
name="Account",
email="account@example.com",
avatar=None,
is_password_set=False,
interface_language="en-US",
interface_theme="light",
timezone="UTC",
last_login_at=None,
last_login_ip=None,
status=status,
initialized_at=None,
created_at=datetime(2026, 1, 1),
)
def test_cloud_initialization_consumes_invitation_and_updates_account_atomically() -> None:
initialized_at = datetime(2026, 8, 10, 12, 0)
accounts = Mock(spec=AccountRepository)
accounts.initialize.return_value = AccountInitializationResult(
status=AccountInitializationStatus.INITIALIZED,
account=_account(status="active"),
)
service = AccountInitializationService(
accounts=accounts,
invitation_required=True,
now=lambda: initialized_at,
)
result = service.initialize(
_context(),
interface_language="zh-Hans",
timezone="Asia/Shanghai",
invitation_code="invite-1",
)
assert result.status == "active"
accounts.initialize.assert_called_once_with(
"account-1",
AccountInitialization(
interface_language="zh-Hans",
interface_theme="light",
timezone="Asia/Shanghai",
initialized_at=initialized_at,
),
invitation_code="invite-1",
workspace_id="workspace-1",
)
def test_cloud_initialization_rejects_missing_or_invalid_invitation() -> None:
accounts = Mock(spec=AccountRepository)
service = AccountInitializationService(
accounts=accounts,
invitation_required=True,
now=lambda: datetime(2026, 8, 10),
)
with pytest.raises(MissingInvitationCodeError):
service.initialize(_context(), interface_language="en-US", timezone="UTC", invitation_code=None)
accounts.initialize.return_value = AccountInitializationResult(
status=AccountInitializationStatus.INVALID_INVITATION
)
with pytest.raises(InvalidInvitationCodeError):
service.initialize(_context(), interface_language="en-US", timezone="UTC", invitation_code="used")
accounts.initialize.assert_called_once()
def test_cloud_initialization_requires_admitted_workspace() -> None:
accounts = Mock(spec=AccountRepository)
service = AccountInitializationService(
accounts=accounts,
invitation_required=True,
now=lambda: datetime(2026, 8, 10),
)
context = RequestContext(
request_id="request-1",
trace_id="trace-1",
account_id="account-1",
active_workspace_id=None,
)
with pytest.raises(ActiveWorkspaceRequiredError):
service.initialize(context, interface_language="en-US", timezone="UTC", invitation_code="invite")
accounts.initialize.assert_not_called()
def test_initialization_rejects_an_active_account_before_consuming_invitation() -> None:
accounts = Mock(spec=AccountRepository)
accounts.initialize.return_value = AccountInitializationResult(
status=AccountInitializationStatus.ALREADY_INITIALIZED
)
service = AccountInitializationService(
accounts=accounts,
invitation_required=True,
now=lambda: datetime(2026, 8, 10),
)
with pytest.raises(AccountAlreadyInitializedError):
service.initialize(_context(), interface_language="en-US", timezone="UTC", invitation_code="invite")