mirror of
https://github.com/langgenius/dify.git
synced 2026-08-29 03:45:08 +08:00
refactor(api): move account lifecycle into application services (#40438)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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: ...
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user