From 3b05b5e553c0c1de713d30aa3cee6e9cfd4ac611 Mon Sep 17 00:00:00 2001 From: "Byron.wang" Date: Fri, 21 Aug 2026 08:05:43 +0000 Subject: [PATCH] refactor(api): move account lifecycle into application services (#40438) --- api/.importlinter | 2 + api/controllers/console/flask_admission.py | 10 +- api/controllers/console/workspace/account.py | 93 +++--- api/controllers/console/workspace/error.py | 6 + api/extensions/ext_application_services.py | 37 ++- api/repositories/account_repository.py | 49 ++- .../workspace_query_repository.py | 9 +- api/services/account_deletion_adapters.py | 76 +++++ api/services/account_deletion_service.py | 56 ++++ api/services/account_errors.py | 24 ++ .../account_initialization_service.py | 65 ++++ api/services/account_ports.py | 35 ++ api/services/account_service.py | 113 +------ .../enterprise/account_deletion_sync.py | 29 +- api/services/entities/account_entities.py | 28 ++ .../services/test_account_service.py | 313 ------------------ .../controllers/console/auth/test_oauth.py | 2 +- .../console/test_workspace_account.py | 25 +- .../controllers/console/test_wraps.py | 47 ++- .../console/workspace/test_accounts.py | 188 ++++++++--- .../console/workspace/test_workspace.py | 5 +- .../test_ext_application_services.py | 17 + api/tests/unit_tests/machinery/test_errors.py | 5 + .../repositories/test_account_repository.py | 46 ++- .../enterprise/test_account_deletion_sync.py | 92 +---- .../test_account_deletion_adapters.py | 57 ++++ .../services/test_account_deletion_service.py | 118 +++++++ .../test_account_initialization_service.py | 138 ++++++++ 28 files changed, 1022 insertions(+), 663 deletions(-) create mode 100644 api/services/account_deletion_adapters.py create mode 100644 api/services/account_deletion_service.py create mode 100644 api/services/account_initialization_service.py create mode 100644 api/tests/unit_tests/services/test_account_deletion_adapters.py create mode 100644 api/tests/unit_tests/services/test_account_deletion_service.py create mode 100644 api/tests/unit_tests/services/test_account_initialization_service.py diff --git a/api/.importlinter b/api/.importlinter index 780a8e03035..193e8cc4a7b 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -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 diff --git a/api/controllers/console/flask_admission.py b/api/controllers/console/flask_admission.py index 424c966953d..c5bd2703c1d 100644 --- a/api/controllers/console/flask_admission.py +++ b/api/controllers/console/flask_admission.py @@ -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) diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index 40368033045..833161e5bfb 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -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") diff --git a/api/controllers/console/workspace/error.py b/api/controllers/console/workspace/error.py index f92aeb6c5ae..09125486711 100644 --- a/api/controllers/console/workspace/error.py +++ b/api/controllers/console/workspace/error.py @@ -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." diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index bfb4c98fc93..2e5b68fb09d 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -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( diff --git a/api/repositories/account_repository.py b/api/repositories/account_repository.py index bb1ba0efa41..bd0c34d1d01 100644 --- a/api/repositories/account_repository.py +++ b/api/repositories/account_repository.py @@ -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( diff --git a/api/repositories/workspace_query_repository.py b/api/repositories/workspace_query_repository.py index d221601f9ea..3e1a7eaa95a 100644 --- a/api/repositories/workspace_query_repository.py +++ b/api/repositories/workspace_query_repository.py @@ -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()) diff --git a/api/services/account_deletion_adapters.py b/api/services/account_deletion_adapters.py new file mode 100644 index 00000000000..cf22354ab9d --- /dev/null +++ b/api/services/account_deletion_adapters.py @@ -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) diff --git a/api/services/account_deletion_service.py b/api/services/account_deletion_service.py new file mode 100644 index 00000000000..c6f9c663b71 --- /dev/null +++ b/api/services/account_deletion_service.py @@ -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) diff --git a/api/services/account_errors.py b/api/services/account_errors.py index 510155d46af..bd251f2cff4 100644 --- a/api/services/account_errors.py +++ b/api/services/account_errors.py @@ -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 diff --git a/api/services/account_initialization_service.py b/api/services/account_initialization_service.py new file mode 100644 index 00000000000..f94de95baca --- /dev/null +++ b/api/services/account_initialization_service.py @@ -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 diff --git a/api/services/account_ports.py b/api/services/account_ports.py index 4428b2152d2..04b99afa568 100644 --- a/api/services/account_ports.py +++ b/api/services/account_ports.py @@ -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: ... diff --git a/api/services/account_service.py b/api/services/account_service.py index 0a58875288d..b0fbfbaf557 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -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 diff --git a/api/services/enterprise/account_deletion_sync.py b/api/services/enterprise/account_deletion_sync.py index 57a306ed094..76c8eedbcbb 100644 --- a/api/services/enterprise/account_deletion_sync.py +++ b/api/services/enterprise/account_deletion_sync.py @@ -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 diff --git a/api/services/entities/account_entities.py b/api/services/entities/account_entities.py index dde47283a2d..21a4d9c802d 100644 --- a/api/services/entities/account_entities.py +++ b/api/services/entities/account_entities.py @@ -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 diff --git a/api/tests/test_containers_integration_tests/services/test_account_service.py b/api/tests/test_containers_integration_tests/services/test_account_service.py index 4d1cea54668..b8d5bcf7668 100644 --- a/api/tests/test_containers_integration_tests/services/test_account_service.py +++ b/api/tests/test_containers_integration_tests/services/test_account_service.py @@ -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 ): diff --git a/api/tests/unit_tests/controllers/console/auth/test_oauth.py b/api/tests/unit_tests/controllers/console/auth/test_oauth.py index e9a40352e54..969e84505aa 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_oauth.py +++ b/api/tests/unit_tests/controllers/console/auth/test_oauth.py @@ -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 diff --git a/api/tests/unit_tests/controllers/console/test_workspace_account.py b/api/tests/unit_tests/controllers/console/test_workspace_account.py index 5316b9efba4..68a1b4dc397 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_account.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_account.py @@ -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", diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index d151a8d4c30..7b5e9d913be 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -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: diff --git a/api/tests/unit_tests/controllers/console/workspace/test_accounts.py b/api/tests/unit_tests/controllers/console/workspace/test_accounts.py index 824ecc67f85..f473f882a8d 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_accounts.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_accounts.py @@ -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: diff --git a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py index dac0b09241d..9c399e18add 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py @@ -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: diff --git a/api/tests/unit_tests/extensions/test_ext_application_services.py b/api/tests/unit_tests/extensions/test_ext_application_services.py index d83c62b5435..32bb9da5970 100644 --- a/api/tests/unit_tests/extensions/test_ext_application_services.py +++ b/api/tests/unit_tests/extensions/test_ext_application_services.py @@ -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"), [ diff --git a/api/tests/unit_tests/machinery/test_errors.py b/api/tests/unit_tests/machinery/test_errors.py index 1d3f2269fd1..b1f884af7cd 100644 --- a/api/tests/unit_tests/machinery/test_errors.py +++ b/api/tests/unit_tests/machinery/test_errors.py @@ -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") diff --git a/api/tests/unit_tests/repositories/test_account_repository.py b/api/tests/unit_tests/repositories/test_account_repository.py index 96720c2387d..6e338d2f61c 100644 --- a/api/tests/unit_tests/repositories/test_account_repository.py +++ b/api/tests/unit_tests/repositories/test_account_repository.py @@ -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" diff --git a/api/tests/unit_tests/services/enterprise/test_account_deletion_sync.py b/api/tests/unit_tests/services/enterprise/test_account_deletion_sync.py index c089dab3628..bfc0d6d30e4 100644 --- a/api/tests/unit_tests/services/enterprise/test_account_deletion_sync.py +++ b/api/tests/unit_tests/services/enterprise/test_account_deletion_sync.py @@ -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"] diff --git a/api/tests/unit_tests/services/test_account_deletion_adapters.py b/api/tests/unit_tests/services/test_account_deletion_adapters.py new file mode 100644 index 00000000000..6ced40b1dd2 --- /dev/null +++ b/api/tests/unit_tests/services/test_account_deletion_adapters.py @@ -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() diff --git a/api/tests/unit_tests/services/test_account_deletion_service.py b/api/tests/unit_tests/services/test_account_deletion_service.py new file mode 100644 index 00000000000..0ba68d9d5f8 --- /dev/null +++ b/api/tests/unit_tests/services/test_account_deletion_service.py @@ -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"), + ] diff --git a/api/tests/unit_tests/services/test_account_initialization_service.py b/api/tests/unit_tests/services/test_account_initialization_service.py new file mode 100644 index 00000000000..020d80353b4 --- /dev/null +++ b/api/tests/unit_tests/services/test_account_initialization_service.py @@ -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")