mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 02:43:49 +08:00
merge: bring origin/main into feat/creator-profile-home
This commit is contained in:
commit
65af1ca9ec
@ -207,6 +207,7 @@ source_modules =
|
||||
services.account_avatar_service
|
||||
services.account_change_email_ports
|
||||
services.account_change_email_service
|
||||
services.account_email_registration_service
|
||||
services.account_deletion_service
|
||||
services.account_deletion_feedback_service
|
||||
services.account_education_service
|
||||
|
||||
@ -7,6 +7,7 @@ from pydantic import AliasChoices, BaseModel, Field, field_validator
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
@ -78,9 +79,11 @@ from services.agent.observability_service import (
|
||||
)
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
from services.app_service import AgentAppPublicationCounts, AppListParams, AppService, CreateAppParams
|
||||
from services.enterprise import rbac_service as enterprise_rbac_service
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.entities.agent_entities import ComposerSavePayload, RosterListQuery
|
||||
from services.feature_service import FeatureService
|
||||
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
|
||||
|
||||
AgentPublicationStatus = Literal["published", "drafts"]
|
||||
|
||||
@ -684,6 +687,15 @@ class AgentAppListApi(Resource):
|
||||
)
|
||||
|
||||
app = AppService().create_app(current_tenant_id, params, current_user, session=session)
|
||||
if dify_config.RBAC_ENABLED:
|
||||
enterprise_rbac_service.RBACService.AppAccess.replace_whitelist(
|
||||
current_tenant_id,
|
||||
current_user.id,
|
||||
str(app.id),
|
||||
enterprise_rbac_service.ReplaceMemberBindings(automatic_include_workspace_members=True),
|
||||
)
|
||||
initialize_created_app_rbac_access_task.delay(current_tenant_id, current_user.id, app_id=app.id)
|
||||
|
||||
return _serialize_agent_app_detail(session, app, current_user=current_user), 201
|
||||
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ from controllers.common.schema import register_response_schema_models, register_
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.error import (
|
||||
AgentSessionConfigurationChangedError,
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
ConversationCompletedError,
|
||||
@ -620,6 +621,10 @@ def _raise_agent_stream_error_before_response(response):
|
||||
if isinstance(response, _ClosableStream):
|
||||
response.close()
|
||||
message = error_payload.get("message")
|
||||
if error_payload.get("code") == AgentSessionConfigurationChangedError.error_code:
|
||||
raise AgentSessionConfigurationChangedError(
|
||||
str(message or AgentSessionConfigurationChangedError.description)
|
||||
)
|
||||
raise CompletionRequestError(str(message or "Agent App chat failed."))
|
||||
|
||||
return _prepend_stream_chunks(buffered, chunk, iterator)
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
from core.app.apps.agent_app.errors import (
|
||||
AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE,
|
||||
AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE,
|
||||
)
|
||||
from libs.exception import BaseHTTPException
|
||||
|
||||
|
||||
@ -49,6 +53,12 @@ class CompletionRequestError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class AgentSessionConfigurationChangedError(BaseHTTPException):
|
||||
error_code = AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE
|
||||
description = AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE
|
||||
code = 409
|
||||
|
||||
|
||||
class AppMoreLikeThisDisabledError(BaseHTTPException):
|
||||
error_code = "app_more_like_this_disabled"
|
||||
description = "The 'More like this' feature is disabled. Please refresh your page."
|
||||
|
||||
@ -2,8 +2,6 @@ from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from configs import dify_config
|
||||
from constants.languages import get_valid_language, languages
|
||||
from controllers.common.fields import SimpleResultDataResponse, VerificationTokenResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
@ -11,31 +9,35 @@ from controllers.console.auth.error import (
|
||||
EmailAlreadyInUseError,
|
||||
EmailCodeError,
|
||||
EmailRegisterLimitError,
|
||||
EmailRegisterRateLimitExceededError,
|
||||
InvalidEmailError,
|
||||
InvalidTokenError,
|
||||
NormalizedEmailAlreadyInUseError,
|
||||
PasswordMismatchError,
|
||||
)
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from controllers.console.flask_admission import console_email_registration_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import EmailStr, extract_remote_ip
|
||||
from libs.helper import EmailStr, dump_response, extract_remote_ip
|
||||
from libs.helper import timezone as validate_timezone_string
|
||||
from libs.password import valid_password
|
||||
from models import Account
|
||||
from services.account_service import AccountService
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.account import (
|
||||
from services.account_errors import (
|
||||
AccountEmailAlreadyInUseError,
|
||||
AccountEmailDomainSuspendedError,
|
||||
AccountEmailFrozenError,
|
||||
AccountNormalizedEmailAlreadyInUseError,
|
||||
AccountRegisterError,
|
||||
SeatsLimitExceededError,
|
||||
)
|
||||
from services.errors.account import (
|
||||
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
|
||||
EmailRegistrationPasswordMismatchError,
|
||||
EmailRegistrationSeatsLimitError,
|
||||
EmailRegistrationSendIPLimitedError,
|
||||
EmailRegistrationSendRateLimitError,
|
||||
EmailRegistrationVerificationLimitError,
|
||||
InvalidEmailRegistrationAddressError,
|
||||
InvalidEmailRegistrationCodeError,
|
||||
InvalidEmailRegistrationTokenError,
|
||||
)
|
||||
|
||||
from ..error import AccountInFreezeError, EmailDomainSuspendedError, EmailSendIpLimitError, SeatsLimitExceeded
|
||||
from ..wraps import email_password_login_enabled, email_register_enabled, model_validate, setup_required
|
||||
|
||||
|
||||
class EmailRegisterSendPayload(BaseModel):
|
||||
@ -91,146 +93,91 @@ register_response_schema_models(
|
||||
|
||||
@console_ns.route("/email-register/send-email")
|
||||
class EmailRegisterSendEmailApi(Resource):
|
||||
@setup_required
|
||||
@email_password_login_enabled
|
||||
@email_register_enabled
|
||||
@console_ns.expect(console_ns.models[EmailRegisterSendPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
|
||||
@console_email_registration_admission
|
||||
@model_validate(EmailRegisterSendPayload)
|
||||
def post(self, req_data: EmailRegisterSendPayload):
|
||||
normalized_email = req_data.email.lower()
|
||||
|
||||
ip_address = extract_remote_ip(request)
|
||||
if AccountService.is_email_send_ip_limit(ip_address):
|
||||
raise EmailSendIpLimitError()
|
||||
language = "en-US"
|
||||
if req_data.language is not None and req_data.language in languages:
|
||||
language = req_data.language
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
||||
freeze_type = BillingService.get_email_freeze_type(normalized_email)
|
||||
if freeze_type:
|
||||
if freeze_type == "email_domain_suspended":
|
||||
raise EmailDomainSuspendedError()
|
||||
raise AccountInFreezeError()
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(req_data.email, session=db.session())
|
||||
token = AccountService.send_email_register_email(email=normalized_email, account=account, language=language)
|
||||
return {"result": "success", "data": token}
|
||||
def post(self, args: EmailRegisterSendPayload):
|
||||
try:
|
||||
token = application_services().accounts.email_registration.send_code(
|
||||
remote_ip=extract_remote_ip(request),
|
||||
requested_email=args.email,
|
||||
requested_language=args.language,
|
||||
)
|
||||
except EmailRegistrationSendIPLimitedError:
|
||||
raise EmailSendIpLimitError() from None
|
||||
except EmailRegistrationSendRateLimitError as error:
|
||||
raise EmailRegisterRateLimitExceededError(error.retry_after_minutes) from None
|
||||
except AccountEmailDomainSuspendedError:
|
||||
raise EmailDomainSuspendedError() from None
|
||||
except AccountEmailFrozenError:
|
||||
raise AccountInFreezeError() from None
|
||||
return dump_response(SimpleResultDataResponse, {"result": "success", "data": token})
|
||||
|
||||
|
||||
@console_ns.route("/email-register/validity")
|
||||
class EmailRegisterCheckApi(Resource):
|
||||
@setup_required
|
||||
@email_password_login_enabled
|
||||
@email_register_enabled
|
||||
@console_ns.expect(console_ns.models[EmailRegisterValidityPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[VerificationTokenResponse.__name__])
|
||||
@console_email_registration_admission
|
||||
@model_validate(EmailRegisterValidityPayload)
|
||||
def post(self, req_data: EmailRegisterValidityPayload):
|
||||
|
||||
user_email = req_data.email.lower()
|
||||
|
||||
is_email_register_error_rate_limit = AccountService.is_email_register_error_rate_limit(user_email)
|
||||
if is_email_register_error_rate_limit:
|
||||
raise EmailRegisterLimitError()
|
||||
|
||||
token_data = AccountService.get_email_register_data(req_data.token)
|
||||
if token_data is None:
|
||||
raise InvalidTokenError()
|
||||
|
||||
token_email = token_data.get("email")
|
||||
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
|
||||
|
||||
if user_email != normalized_token_email:
|
||||
raise InvalidEmailError()
|
||||
|
||||
if req_data.code != token_data.get("code"):
|
||||
AccountService.add_email_register_error_rate_limit(user_email)
|
||||
raise EmailCodeError()
|
||||
|
||||
# Verified, revoke the first token
|
||||
AccountService.revoke_email_register_token(req_data.token)
|
||||
|
||||
# Refresh token data by generating a new token
|
||||
_, new_token = AccountService.generate_email_register_token(
|
||||
user_email, code=req_data.code, additional_data={"phase": "register"}
|
||||
def post(self, args: EmailRegisterValidityPayload):
|
||||
try:
|
||||
verification = application_services().accounts.email_registration.verify_code(
|
||||
email=args.email,
|
||||
code=args.code,
|
||||
token=args.token,
|
||||
)
|
||||
except EmailRegistrationVerificationLimitError:
|
||||
raise EmailRegisterLimitError() from None
|
||||
except InvalidEmailRegistrationTokenError:
|
||||
raise InvalidTokenError() from None
|
||||
except InvalidEmailRegistrationAddressError:
|
||||
raise InvalidEmailError() from None
|
||||
except InvalidEmailRegistrationCodeError:
|
||||
raise EmailCodeError() from None
|
||||
return dump_response(
|
||||
VerificationTokenResponse,
|
||||
{
|
||||
"is_valid": True,
|
||||
"email": verification.email,
|
||||
"token": verification.token,
|
||||
},
|
||||
)
|
||||
|
||||
AccountService.reset_email_register_error_rate_limit(user_email)
|
||||
return {"is_valid": True, "email": normalized_token_email, "token": new_token}
|
||||
|
||||
|
||||
@console_ns.route("/email-register")
|
||||
class EmailRegisterResetApi(Resource):
|
||||
@setup_required
|
||||
@email_password_login_enabled
|
||||
@email_register_enabled
|
||||
@console_ns.expect(console_ns.models[EmailRegisterResetPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[EmailRegisterResetResponse.__name__])
|
||||
@console_email_registration_admission
|
||||
@model_validate(EmailRegisterResetPayload)
|
||||
def post(self, req_data: EmailRegisterResetPayload):
|
||||
|
||||
# Validate passwords match
|
||||
if req_data.new_password != req_data.password_confirm:
|
||||
raise PasswordMismatchError()
|
||||
|
||||
# Validate token and get register data
|
||||
register_data = AccountService.get_email_register_data(req_data.token)
|
||||
if not register_data:
|
||||
raise InvalidTokenError()
|
||||
# Must use token in reset phase
|
||||
if register_data.get("phase", "") != "register":
|
||||
raise InvalidTokenError()
|
||||
|
||||
# Revoke token to prevent reuse
|
||||
AccountService.revoke_email_register_token(req_data.token)
|
||||
|
||||
email = register_data.get("email", "")
|
||||
normalized_email = email.lower()
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(email, session=db.session())
|
||||
|
||||
if account:
|
||||
raise EmailAlreadyInUseError()
|
||||
|
||||
ip_address = extract_remote_ip(request)
|
||||
account = self._create_new_account(
|
||||
email=normalized_email,
|
||||
password=req_data.password_confirm,
|
||||
timezone=req_data.timezone,
|
||||
language=req_data.language,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
token_pair = AccountService.login(account=account, session=db.session(), ip_address=ip_address)
|
||||
AccountService.reset_login_error_rate_limit(normalized_email)
|
||||
|
||||
return {"result": "success", "data": token_pair.model_dump()}
|
||||
|
||||
def _create_new_account(
|
||||
self,
|
||||
email: str,
|
||||
password: str,
|
||||
timezone: str | None = None,
|
||||
language: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
) -> Account:
|
||||
def post(self, args: EmailRegisterResetPayload):
|
||||
try:
|
||||
return AccountService.create_account_and_tenant(
|
||||
email=email,
|
||||
name=email,
|
||||
password=password,
|
||||
interface_language=get_valid_language(language),
|
||||
timezone=timezone,
|
||||
ip_address=ip_address,
|
||||
check_normalized_email=True,
|
||||
session=db.session(),
|
||||
token_pair = application_services().accounts.email_registration.register(
|
||||
remote_ip=extract_remote_ip(request),
|
||||
token=args.token,
|
||||
new_password=args.new_password,
|
||||
password_confirm=args.password_confirm,
|
||||
language=args.language,
|
||||
timezone=args.timezone,
|
||||
)
|
||||
except SeatsLimitExceededError:
|
||||
raise SeatsLimitExceeded()
|
||||
except EmailDomainSuspendedRegistrationError as exc:
|
||||
raise EmailDomainSuspendedError() from exc
|
||||
except AccountNormalizedEmailAlreadyInUseError as exc:
|
||||
raise NormalizedEmailAlreadyInUseError() from exc
|
||||
except AccountRegisterError as exc:
|
||||
raise AccountInFreezeError() from exc
|
||||
except EmailRegistrationPasswordMismatchError:
|
||||
raise PasswordMismatchError() from None
|
||||
except InvalidEmailRegistrationTokenError:
|
||||
raise InvalidTokenError() from None
|
||||
except AccountNormalizedEmailAlreadyInUseError:
|
||||
raise NormalizedEmailAlreadyInUseError() from None
|
||||
except AccountEmailAlreadyInUseError:
|
||||
raise EmailAlreadyInUseError() from None
|
||||
except EmailRegistrationSeatsLimitError:
|
||||
raise SeatsLimitExceeded() from None
|
||||
except AccountEmailDomainSuspendedError:
|
||||
raise EmailDomainSuspendedError() from None
|
||||
except AccountEmailFrozenError:
|
||||
raise AccountInFreezeError() from None
|
||||
|
||||
return dump_response(
|
||||
EmailRegisterResetResponse,
|
||||
{"result": "success", "data": token_pair},
|
||||
)
|
||||
|
||||
@ -22,6 +22,22 @@ from libs.login import current_account_with_tenant, login_required
|
||||
from machinery.context import RequestContext
|
||||
from machinery.errors import AdmissionConfigurationError
|
||||
from models.account import TenantAccountRole
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
||||
def console_email_registration_admission[T, **P, R](
|
||||
view: Callable[Concatenate[T, P], R],
|
||||
) -> Callable[Concatenate[T, P], R | Response]:
|
||||
"""Apply the complete admission policy for anonymous email registration."""
|
||||
|
||||
@wraps(view)
|
||||
def check_registration_features(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
features = FeatureService.get_system_features()
|
||||
if not features.enable_email_password_login or not features.is_allow_register:
|
||||
abort(403)
|
||||
return view(self, *args, **kwargs)
|
||||
|
||||
return setup_required(check_registration_features)
|
||||
|
||||
|
||||
def console_account_admission[T, **P, R](
|
||||
|
||||
@ -1,56 +1,16 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import TypedDict
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
model_validate,
|
||||
only_edition_cloud,
|
||||
setup_required,
|
||||
with_current_user,
|
||||
)
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from services.billing_service import BillingService
|
||||
|
||||
# Notification content is stored under three lang tags.
|
||||
_FALLBACK_LANG = "en-US"
|
||||
|
||||
|
||||
class NotificationLangContent(TypedDict, total=False):
|
||||
lang: str
|
||||
title: str
|
||||
subtitle: str
|
||||
body: str
|
||||
titlePicUrl: str
|
||||
|
||||
|
||||
class NotificationItemDict(TypedDict):
|
||||
notification_id: str | None
|
||||
frequency: str | None
|
||||
lang: str
|
||||
title: str
|
||||
subtitle: str
|
||||
body: str
|
||||
title_pic_url: str
|
||||
|
||||
|
||||
class NotificationResponseDict(TypedDict):
|
||||
should_show: bool
|
||||
notifications: list[NotificationItemDict]
|
||||
|
||||
|
||||
def _pick_lang_content(contents: Mapping[str, NotificationLangContent], lang: str) -> NotificationLangContent:
|
||||
"""Return the single LangContent for *lang*, falling back to English."""
|
||||
return (
|
||||
contents.get(lang) or contents.get(_FALLBACK_LANG) or next(iter(contents.values()), NotificationLangContent())
|
||||
)
|
||||
from libs.helper import dump_response
|
||||
from machinery.context import RequestContext
|
||||
|
||||
|
||||
class DismissNotificationPayload(BaseModel):
|
||||
@ -92,39 +52,10 @@ class NotificationApi(Resource):
|
||||
},
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[NotificationResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@with_current_user
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
def get(self, current_user: Account):
|
||||
result = BillingService.get_account_notification(str(current_user.id))
|
||||
|
||||
# Proto JSON uses camelCase field names (Kratos default marshaling).
|
||||
response: NotificationResponseDict
|
||||
if not result.get("shouldShow"):
|
||||
response = {"should_show": False, "notifications": []}
|
||||
return response, 200
|
||||
|
||||
lang = current_user.interface_language or _FALLBACK_LANG
|
||||
|
||||
notifications: list[NotificationItemDict] = []
|
||||
for notification in result.get("notifications") or []:
|
||||
contents: Mapping[str, NotificationLangContent] = notification.get("contents") or {}
|
||||
lang_content = _pick_lang_content(contents, lang)
|
||||
item: NotificationItemDict = {
|
||||
"notification_id": notification.get("notificationId"),
|
||||
"frequency": notification.get("frequency"),
|
||||
"lang": lang_content.get("lang", lang),
|
||||
"title": lang_content.get("title", ""),
|
||||
"subtitle": lang_content.get("subtitle", ""),
|
||||
"body": lang_content.get("body", ""),
|
||||
"title_pic_url": lang_content.get("titlePicUrl", ""),
|
||||
}
|
||||
notifications.append(item)
|
||||
|
||||
response = {"should_show": bool(notifications), "notifications": notifications}
|
||||
return response, 200
|
||||
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
|
||||
def get(self, request_context: RequestContext):
|
||||
result = application_services().notifications.get_active(request_context)
|
||||
return dump_response(NotificationResponse, result), 200
|
||||
|
||||
|
||||
@console_ns.route("/notification/dismiss")
|
||||
@ -134,17 +65,10 @@ class NotificationDismissApi(Resource):
|
||||
description="Mark a notification as dismissed for the current user.",
|
||||
responses={200: "Success", 401: "Unauthorized"},
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@with_current_user
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
|
||||
@console_ns.expect(console_ns.models[DismissNotificationPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@model_validate(DismissNotificationPayload)
|
||||
def post(self, payload: DismissNotificationPayload, current_user: Account):
|
||||
BillingService.dismiss_notification(
|
||||
notification_id=payload.notification_id,
|
||||
account_id=str(current_user.id),
|
||||
)
|
||||
return {"result": "success"}, 200
|
||||
def post(self, payload: DismissNotificationPayload, request_context: RequestContext):
|
||||
application_services().notifications.dismiss(request_context, payload.notification_id)
|
||||
return dump_response(SimpleResultResponse, {"result": "success"}), 200
|
||||
|
||||
@ -7,36 +7,20 @@ action-based so callers do not replace server-side arrays with stale snapshots.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, cast
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from extensions.ext_database import db
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from services.step_by_step_tour_service import StepByStepTourPatch, StepByStepTourService
|
||||
from machinery.context import RequestContext
|
||||
from services.entities.onboarding_entities import StepByStepTourAction, StepByStepTourPatch, StepByStepTourTaskId
|
||||
|
||||
from . import console_ns
|
||||
from .wraps import (
|
||||
account_initialization_required,
|
||||
model_validate,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
|
||||
StepByStepTourAction = Literal[
|
||||
"skip",
|
||||
"complete_task",
|
||||
"uncomplete_task",
|
||||
"enable_current_workspace",
|
||||
"disable_current_workspace",
|
||||
]
|
||||
StepByStepTourTaskId = Literal["home", "studio", "knowledge", "integration"]
|
||||
|
||||
|
||||
class StepByStepTourStatePatchPayload(BaseModel):
|
||||
@ -74,39 +58,22 @@ class StepByStepTourStateApi(Resource):
|
||||
@console_ns.doc("get_step_by_step_tour_state")
|
||||
@console_ns.doc(description="Get account-level Step-by-step Tour state")
|
||||
@console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account):
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext):
|
||||
return dump_response(
|
||||
StepByStepTourStateResponse,
|
||||
StepByStepTourService.get_state(
|
||||
account=current_user,
|
||||
current_tenant_id=current_tenant_id,
|
||||
session=db.session,
|
||||
),
|
||||
application_services().step_by_step_tour.get_state(request_context),
|
||||
)
|
||||
|
||||
@console_ns.doc("patch_step_by_step_tour_state")
|
||||
@console_ns.doc(description="Update account-level Step-by-step Tour state")
|
||||
@console_ns.expect(console_ns.models[StepByStepTourStatePatchPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@console_account_admission()
|
||||
@model_validate(StepByStepTourStatePatchPayload)
|
||||
def patch(self, req_data: StepByStepTourStatePatchPayload, current_tenant_id: str, current_user: Account):
|
||||
patch = cast(StepByStepTourPatch, req_data.model_dump(exclude_unset=True, exclude_none=True))
|
||||
def patch(self, req_data: StepByStepTourStatePatchPayload, request_context: RequestContext):
|
||||
patch = StepByStepTourPatch(action=req_data.action, task_id=req_data.task_id)
|
||||
return dump_response(
|
||||
StepByStepTourStateResponse,
|
||||
StepByStepTourService.patch_state(
|
||||
account=current_user,
|
||||
current_tenant_id=current_tenant_id,
|
||||
patch=patch,
|
||||
session=db.session,
|
||||
),
|
||||
application_services().step_by_step_tour.patch_state(request_context, patch),
|
||||
)
|
||||
|
||||
@ -352,19 +352,6 @@ def email_password_login_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R]
|
||||
return decorated
|
||||
|
||||
|
||||
def email_register_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
features = FeatureService.get_system_features()
|
||||
if features.is_allow_register:
|
||||
return view(*args, **kwargs)
|
||||
|
||||
# otherwise, return 403
|
||||
abort(403)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def enable_change_email[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
@ -652,6 +639,23 @@ def with_current_user_id[T, **P, R](
|
||||
return decorated
|
||||
|
||||
|
||||
def validate_request[M: BaseModel](model: type[M]) -> M:
|
||||
"""Parse and validate the current request without exposing submitted values."""
|
||||
|
||||
if request.method == "GET":
|
||||
raw = request.args.to_dict(flat=True)
|
||||
elif request.method == "DELETE":
|
||||
raw = request.args.to_dict(flat=True) or (request.get_json(silent=True) or {})
|
||||
else:
|
||||
raw = request.get_json(silent=True) or {}
|
||||
|
||||
try:
|
||||
return model.model_validate(raw)
|
||||
except ValidationError as exc:
|
||||
errors = exc.errors(include_url=False, include_input=False, include_context=False)
|
||||
raise UnprocessableEntity(json.dumps(errors)) from None
|
||||
|
||||
|
||||
def model_validate[T, M: BaseModel, **P, R](
|
||||
model: type[M],
|
||||
) -> Callable[
|
||||
@ -671,19 +675,7 @@ def model_validate[T, M: BaseModel, **P, R](
|
||||
) -> Callable[Concatenate[T, P], R]:
|
||||
@wraps(view)
|
||||
def wrapper(self: T, *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
if request.method == "GET":
|
||||
raw = request.args.to_dict(flat=True)
|
||||
elif request.method == "DELETE":
|
||||
raw = request.args.to_dict(flat=True) or (request.get_json(silent=True) or {})
|
||||
else:
|
||||
raw = request.get_json(silent=True) or {}
|
||||
|
||||
try:
|
||||
validated = model.model_validate(raw)
|
||||
except ValidationError as exc:
|
||||
raise UnprocessableEntity(exc.json())
|
||||
|
||||
return view(self, validated, *args, **kwargs)
|
||||
return view(self, validate_request(model), *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@ -67,6 +67,7 @@ class OpenApiErrorCode(StrEnum):
|
||||
MEMBER_LICENSE_EXCEEDED = "member_license_exceeded"
|
||||
HUMAN_INPUT_FORM_NOT_FOUND = "form_not_found"
|
||||
RECIPIENT_SURFACE_MISMATCH = "recipient_surface_mismatch"
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE = "trigger_workflow_service_mode_unavailable"
|
||||
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
|
||||
@ -35,6 +35,7 @@ from controllers.service_api.app.error import (
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
TriggerWorkflowServiceModeUnavailableError,
|
||||
)
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
@ -57,6 +58,9 @@ from services.errors.app import (
|
||||
WorkflowIdFormatError,
|
||||
WorkflowNotFoundError,
|
||||
)
|
||||
from services.errors.app import (
|
||||
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
|
||||
)
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -70,6 +74,8 @@ def _translate_service_errors() -> Generator[None, None, None]:
|
||||
raise NotFound(str(ex))
|
||||
except (IsDraftWorkflowError, WorkflowIdFormatError) as ex:
|
||||
raise BadRequest(str(ex))
|
||||
except TriggerWorkflowServiceModeUnavailableServiceError:
|
||||
raise TriggerWorkflowServiceModeUnavailableError()
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
except services.errors.conversation.ConversationCompletedError:
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.errors.app import (
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE,
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE,
|
||||
)
|
||||
|
||||
|
||||
class AppUnavailableError(BaseHTTPException):
|
||||
@ -37,6 +41,12 @@ class WorkflowVersionExecutionNotAllowedError(BaseHTTPException):
|
||||
code = 403
|
||||
|
||||
|
||||
class TriggerWorkflowServiceModeUnavailableError(BaseHTTPException):
|
||||
error_code = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE
|
||||
description = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE
|
||||
code = 403
|
||||
|
||||
|
||||
class ConversationCompletedError(BaseHTTPException):
|
||||
error_code = "conversation_completed"
|
||||
description = "The conversation has ended. Please start a new conversation."
|
||||
|
||||
@ -28,6 +28,7 @@ from controllers.service_api.app.error import (
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
TriggerWorkflowServiceModeUnavailableError,
|
||||
WorkflowVersionExecutionNotAllowedError,
|
||||
)
|
||||
from controllers.service_api.schema import (
|
||||
@ -61,7 +62,14 @@ from models.model import App, AppMode, EndUser
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError
|
||||
from services.errors.app import (
|
||||
IsDraftWorkflowError,
|
||||
WorkflowIdFormatError,
|
||||
WorkflowNotFoundError,
|
||||
)
|
||||
from services.errors.app import (
|
||||
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
|
||||
)
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.workflow_app_service import WorkflowAppService
|
||||
|
||||
@ -300,6 +308,11 @@ class WorkflowRunApi(Resource):
|
||||
"- `completion_request_error` : Workflow execution request failed.\n"
|
||||
"- `invalid_param` : Invalid parameter value."
|
||||
),
|
||||
403: (
|
||||
"- `forbidden` : Token scope, app, or workspace access denied.\n"
|
||||
"- `trigger_workflow_service_mode_unavailable` : Trigger-entry workflows cannot be invoked through "
|
||||
"Web App, Service API, OpenAPI, or MCP."
|
||||
),
|
||||
429: (
|
||||
"- `too_many_requests` : Too many concurrent requests for this app.\n"
|
||||
"- `rate_limit_error` : The upstream model provider rate limit was exceeded."
|
||||
@ -360,6 +373,8 @@ class WorkflowRunApi(Resource):
|
||||
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
except TriggerWorkflowServiceModeUnavailableServiceError:
|
||||
raise TriggerWorkflowServiceModeUnavailableError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
@ -406,8 +421,11 @@ class WorkflowRunByIdApi(Resource):
|
||||
"- `invalid_param` : Required parameter missing or invalid."
|
||||
),
|
||||
403: (
|
||||
"`workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the "
|
||||
"current plan. Upgrade to a paid plan."
|
||||
"- `forbidden` : Token scope, app, or workspace access denied.\n"
|
||||
"- `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the "
|
||||
"current plan. Upgrade to a paid plan.\n"
|
||||
"- `trigger_workflow_service_mode_unavailable` : The selected workflow version uses a trigger entry "
|
||||
"and cannot be invoked through Web App, Service API, OpenAPI, or MCP."
|
||||
),
|
||||
404: "`not_found` : Workflow not found.",
|
||||
429: (
|
||||
@ -487,6 +505,8 @@ class WorkflowRunByIdApi(Resource):
|
||||
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
except TriggerWorkflowServiceModeUnavailableServiceError:
|
||||
raise TriggerWorkflowServiceModeUnavailableError()
|
||||
except WorkflowNotFoundError as ex:
|
||||
raise NotFound(str(ex))
|
||||
except IsDraftWorkflowError as ex:
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.errors.app import (
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE,
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE,
|
||||
)
|
||||
|
||||
|
||||
class AppUnavailableError(BaseHTTPException):
|
||||
@ -31,6 +35,12 @@ class NotWorkflowAppError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class TriggerWorkflowServiceModeUnavailableError(BaseHTTPException):
|
||||
error_code = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE
|
||||
description = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE
|
||||
code = 403
|
||||
|
||||
|
||||
class ConversationCompletedError(BaseHTTPException):
|
||||
error_code = "conversation_completed"
|
||||
description = "The conversation has ended. Please start a new conversation."
|
||||
|
||||
@ -14,6 +14,7 @@ from controllers.web.error import (
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
TriggerWorkflowServiceModeUnavailableError,
|
||||
)
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from controllers.web.wraps import WebApiResource
|
||||
@ -30,6 +31,9 @@ from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs import helper
|
||||
from models.model import App, AppMode, EndUser
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.errors.app import (
|
||||
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
|
||||
)
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -78,6 +82,8 @@ class WorkflowRunApi(WebApiResource):
|
||||
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
except TriggerWorkflowServiceModeUnavailableServiceError:
|
||||
raise TriggerWorkflowServiceModeUnavailableError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
|
||||
@ -31,7 +31,11 @@ from core.agent.publish_visibility import agent_has_workflow_callable_active_sna
|
||||
from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter
|
||||
from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager
|
||||
from core.app.apps.agent_app.app_runner import AgentAppRunner
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
from core.app.apps.agent_app.errors import (
|
||||
AgentAppGeneratorError,
|
||||
AgentAppNotPublishedError,
|
||||
AgentSessionSnapshotIncompatibleError,
|
||||
)
|
||||
from core.app.apps.agent_app.generate_response_converter import AgentAppGenerateResponseConverter
|
||||
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder
|
||||
from core.app.apps.agent_app.session_store import AgentAppWorkspaceStore
|
||||
@ -531,6 +535,15 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
)
|
||||
except GenerateTaskStoppedError:
|
||||
pass
|
||||
except AgentSessionSnapshotIncompatibleError as error:
|
||||
logger.info(
|
||||
"Agent App session snapshot no longer matches the current composition",
|
||||
extra={
|
||||
"agent_id": application_generate_entity.agent_id,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
queue_manager.publish_error(error, PublishFrom.APPLICATION_MANAGER)
|
||||
except Exception as e:
|
||||
logger.exception("Unknown Error in Agent App generate worker")
|
||||
queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER)
|
||||
|
||||
@ -1,6 +1,24 @@
|
||||
from core.app.apps.exc import AppGenerateError
|
||||
|
||||
AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE = "agent_session_configuration_changed"
|
||||
AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE = (
|
||||
"The Agent configuration changed after this conversation started. Start a new conversation to continue."
|
||||
)
|
||||
|
||||
|
||||
class AgentAppGeneratorError(ValueError):
|
||||
"""Raised when an Agent App turn cannot be set up."""
|
||||
|
||||
|
||||
class AgentAppNotPublishedError(AgentAppGeneratorError):
|
||||
"""Raised when a public Agent App runtime is requested before publish."""
|
||||
|
||||
|
||||
class AgentSessionSnapshotIncompatibleError(AppGenerateError):
|
||||
"""Raised when a retained session snapshot no longer matches the current composition."""
|
||||
|
||||
error_code = AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE
|
||||
status_code = 409
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE)
|
||||
|
||||
@ -50,6 +50,8 @@ from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig
|
||||
from models.provider_ids import ModelProviderID
|
||||
from services.agent.prompt_mentions import expand_prompt_mentions
|
||||
|
||||
from .errors import AgentSessionSnapshotIncompatibleError
|
||||
|
||||
|
||||
class AgentAppRuntimeRequestBuildError(ValueError):
|
||||
"""Raised when Agent App state cannot be mapped to a valid run request."""
|
||||
@ -191,6 +193,7 @@ class AgentAppRuntimeRequestBuilder:
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
self._validate_session_snapshot_layers(request)
|
||||
redacted = cast(dict[str, Any], redact_for_agent_backend_log(request))
|
||||
return AgentAppRuntimeRequest(
|
||||
request=request,
|
||||
@ -199,6 +202,24 @@ class AgentAppRuntimeRequestBuilder:
|
||||
binding_id=context.binding_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_session_snapshot_layers(request: CreateRunRequest) -> None:
|
||||
"""Reject stale snapshots before they reach the Agent backend.
|
||||
|
||||
Draft rows are updated in place, so their IDs cannot prove that a
|
||||
retained snapshot still belongs to the current composition. Agenton
|
||||
requires the ordered layer names to match exactly; enforce the same
|
||||
invariant at the API boundary and return a product-level error.
|
||||
"""
|
||||
|
||||
snapshot = request.session_snapshot
|
||||
if snapshot is None:
|
||||
return
|
||||
snapshot_layer_names = tuple(layer.name for layer in snapshot.layers)
|
||||
composition_layer_names = tuple(layer.name for layer in request.composition.layers)
|
||||
if snapshot_layer_names != composition_layer_names:
|
||||
raise AgentSessionSnapshotIncompatibleError()
|
||||
|
||||
def _build_tool_layers(
|
||||
self,
|
||||
*,
|
||||
|
||||
@ -7,6 +7,7 @@ from dify_agent.protocol import RunFailureType
|
||||
from pydantic import JsonValue
|
||||
|
||||
from clients.agent_backend.errors import AgentBackendError, AgentBackendRunFailedError
|
||||
from core.app.apps.exc import AppGenerateError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.entities.task_entities import AppBlockingResponse, AppStreamResponse
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
@ -125,6 +126,13 @@ class AppGenerateResponseConverter[TBlockingResponse: AppBlockingResponse](ABC):
|
||||
"message": str(e),
|
||||
}
|
||||
|
||||
if isinstance(e, AppGenerateError):
|
||||
return {
|
||||
"code": e.error_code,
|
||||
"status": e.status_code,
|
||||
"message": str(e),
|
||||
}
|
||||
|
||||
error_responses: dict[type[Exception], dict[str, JsonValue]] = {
|
||||
ValueError: {"code": "invalid_param", "status": 400},
|
||||
ProviderTokenNotInitError: {"code": "provider_not_initialize", "status": 400},
|
||||
|
||||
@ -1,2 +1,9 @@
|
||||
class AppGenerateError(ValueError):
|
||||
"""Base class for application-generation errors with a stable response contract."""
|
||||
|
||||
error_code: str
|
||||
status_code: int
|
||||
|
||||
|
||||
class GenerateTaskStoppedError(Exception):
|
||||
pass
|
||||
|
||||
@ -12,6 +12,7 @@ from core.mcp import types as mcp_types
|
||||
from graphon.variables.input_entities import VariableEntity, VariableEntityType
|
||||
from models.model import App, AppMCPServer, AppMode, EndUser
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.errors.app import TriggerWorkflowServiceModeUnavailableError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -93,11 +94,16 @@ def handle_mcp_request(
|
||||
result=result_data.model_dump(by_alias=True, mode="json", exclude_none=True),
|
||||
)
|
||||
|
||||
def create_error_response(code: int, message: str) -> mcp_types.JSONRPCError:
|
||||
def create_error_response(
|
||||
code: int,
|
||||
message: str,
|
||||
*,
|
||||
data: Mapping[str, Any] | None = None,
|
||||
) -> mcp_types.JSONRPCError:
|
||||
"""Create error response with error code and message"""
|
||||
from core.mcp.types import ErrorData
|
||||
|
||||
error_data = ErrorData(code=code, message=message)
|
||||
error_data = ErrorData(code=code, message=message, data=data)
|
||||
return mcp_types.JSONRPCError(
|
||||
jsonrpc="2.0",
|
||||
id=request_id,
|
||||
@ -131,6 +137,12 @@ def handle_mcp_request(
|
||||
case _:
|
||||
return create_error_response(mcp_types.METHOD_NOT_FOUND, f"Method not found: {request_type.__name__}")
|
||||
|
||||
except TriggerWorkflowServiceModeUnavailableError as e:
|
||||
return create_error_response(
|
||||
mcp_types.INVALID_REQUEST,
|
||||
str(e),
|
||||
data={"code": e.error_code},
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.exception("Invalid params")
|
||||
return create_error_response(mcp_types.INVALID_PARAMS, str(e))
|
||||
|
||||
@ -76,6 +76,10 @@ def _schema_markdown_type(schema: object) -> str:
|
||||
item_type = _schema_markdown_type(schema.get("items"))
|
||||
return f"[ {item_type or 'object'} ]"
|
||||
if isinstance(schema_type, str):
|
||||
enum_values = schema.get("enum")
|
||||
if isinstance(enum_values, list) and enum_values:
|
||||
rendered_values = ", ".join(json.dumps(value, ensure_ascii=False) for value in enum_values)
|
||||
return f"{schema_type}, <br>**Available values:** {rendered_values}"
|
||||
return schema_type
|
||||
|
||||
return ""
|
||||
|
||||
@ -31,6 +31,7 @@ from repositories.factory import DifyAPIRepositoryFactory
|
||||
from repositories.installation_state_repository import InstallationStateRepository
|
||||
from repositories.oauth_server_repository import RedisOAuthServerTokenRepository, SQLAlchemyOAuthServerRepository
|
||||
from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository
|
||||
from repositories.step_by_step_tour_repository import SQLAlchemyStepByStepTourStateRepository
|
||||
from repositories.tag_repository import TagRepository
|
||||
from repositories.trial_app_query_repository import TrialAppQueryRepository
|
||||
from repositories.trial_app_usage_repository import TrialAppUsageRepository
|
||||
@ -70,6 +71,16 @@ from services.account_deletion_adapters import (
|
||||
from services.account_deletion_feedback_service import AccountDeletionFeedbackService
|
||||
from services.account_deletion_service import AccountDeletionService
|
||||
from services.account_education_service import AccountEducationService
|
||||
from services.account_email_registration_adapters import (
|
||||
AccountServiceRegistrationGateway,
|
||||
BillingAccountRegistrationPolicyGateway,
|
||||
CeleryEmailRegistrationNotificationGateway,
|
||||
RateLimiterEmailRegistrationSendLimiter,
|
||||
RedisEmailRegistrationSecurityGateway,
|
||||
SecureEmailRegistrationCodeGenerator,
|
||||
TokenManagerEmailRegistrationTokenGateway,
|
||||
)
|
||||
from services.account_email_registration_service import AccountEmailRegistrationService
|
||||
from services.account_initialization_service import AccountInitializationService
|
||||
from services.account_integration_service import AccountIntegrationService
|
||||
from services.account_password_hasher import LegacyAccountPasswordHasher
|
||||
@ -94,6 +105,8 @@ from services.feature_service import FeatureService
|
||||
from services.feature_service_gateway import FeatureServiceGateway
|
||||
from services.file_service import FileService
|
||||
from services.init_validation_service import InitValidationService
|
||||
from services.notification_gateway import BillingNotificationGateway
|
||||
from services.notification_service import NotificationService
|
||||
from services.notion_data_source_gateway import NotionDataSourceGateway
|
||||
from services.oauth_server_service import OAUTH_ACCESS_TOKEN_EXPIRES_IN, OAuthServerService
|
||||
from services.partner_tenant_binding_service import PartnerTenantBindingService
|
||||
@ -112,6 +125,7 @@ from services.retention.workflow_run.archive_log_service import WorkflowRunArchi
|
||||
from services.schema_definition_service import SchemaDefinitionService
|
||||
from services.setup_adapters import RedisSetupLock, RegisterServiceAccountProvisioner
|
||||
from services.setup_service import SetupService
|
||||
from services.step_by_step_tour_service import StepByStepTourService
|
||||
from services.tag_application_service import TagApplicationService
|
||||
from services.trial_app_usage import TrialAppUsageRecorder
|
||||
from services.web_app_runtime_query_service import WebAppRuntimeQueryService
|
||||
@ -150,6 +164,7 @@ def _is_user_allowed_to_access_webapp(user_id: str, app_id: str) -> bool:
|
||||
class AccountServices:
|
||||
avatar: AccountAvatarService
|
||||
change_email: AccountChangeEmailService
|
||||
email_registration: AccountEmailRegistrationService
|
||||
deletion: AccountDeletionService
|
||||
deletion_feedback: AccountDeletionFeedbackService
|
||||
education: AccountEducationService
|
||||
@ -177,6 +192,8 @@ class ApplicationServices:
|
||||
feature_queries: FeatureQueryService
|
||||
oauth_server: OAuthServerService
|
||||
init_validation: InitValidationService
|
||||
notifications: NotificationService
|
||||
step_by_step_tour: StepByStepTourService
|
||||
partner_tenant_bindings: PartnerTenantBindingService
|
||||
recommended_app_queries: RecommendedAppQueryService
|
||||
trial_app_usage: TrialAppUsageRecorder
|
||||
@ -278,6 +295,29 @@ def build_application_services(
|
||||
billing_enabled=deployment_edition == DeploymentEdition.CLOUD,
|
||||
),
|
||||
),
|
||||
email_registration=AccountEmailRegistrationService(
|
||||
accounts=accounts,
|
||||
tokens=TokenManagerEmailRegistrationTokenGateway(),
|
||||
codes=SecureEmailRegistrationCodeGenerator(),
|
||||
notifications=CeleryEmailRegistrationNotificationGateway(),
|
||||
send_limits=RateLimiterEmailRegistrationSendLimiter(
|
||||
rate_limiter=RateLimiter(
|
||||
prefix="email_register_rate_limit",
|
||||
max_attempts=1,
|
||||
time_window=60,
|
||||
redis_client=redis,
|
||||
)
|
||||
),
|
||||
security=RedisEmailRegistrationSecurityGateway(
|
||||
redis=redis,
|
||||
verification_failure_limit=5,
|
||||
verification_lockout_duration=dify_config.EMAIL_REGISTER_LOCKOUT_DURATION,
|
||||
),
|
||||
account_policy=BillingAccountRegistrationPolicyGateway(
|
||||
enabled=deployment_edition == DeploymentEdition.CLOUD,
|
||||
),
|
||||
registration=AccountServiceRegistrationGateway(session_factory=database_client),
|
||||
),
|
||||
deletion=AccountDeletionService(
|
||||
accounts=accounts,
|
||||
memberships=workspace_query_repository,
|
||||
@ -400,6 +440,16 @@ def build_application_services(
|
||||
validation_required=(deployment_edition != DeploymentEdition.CLOUD and bool(initialization_password)),
|
||||
expected_password=initialization_password,
|
||||
),
|
||||
notifications=NotificationService(
|
||||
accounts=accounts,
|
||||
notifications=BillingNotificationGateway(),
|
||||
),
|
||||
step_by_step_tour=StepByStepTourService(
|
||||
accounts=accounts,
|
||||
states=SQLAlchemyStepByStepTourStateRepository(session_factory=database_client),
|
||||
enabled=dify_config.ENABLE_STEP_BY_STEP_TOUR,
|
||||
rollout_started_at=dify_config.STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT,
|
||||
),
|
||||
partner_tenant_bindings=PartnerTenantBindingService(
|
||||
sync_bindings=BillingService.sync_partner_tenants_bindings,
|
||||
),
|
||||
|
||||
@ -13501,7 +13501,7 @@ default (the config form sends the full desired feature state on save).
|
||||
| mode | string, <br>**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow", <br>**Default:** all | App mode filter<br>*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No |
|
||||
| name | string | Filter by app name | No |
|
||||
| page | integer, <br>**Default:** 1 | Page number (1-99999) | No |
|
||||
| publication_status | string | Filter by published or draft Agent configuration status | No |
|
||||
| publication_status | string, <br>**Available values:** "drafts", "published" | Filter by published or draft Agent configuration status | No |
|
||||
| sort_by | string, <br>**Available values:** "earliest_created", "last_modified", "recently_created", <br>**Default:** last_modified | Sort apps by last modified, recently created, or earliest created<br>*Enum:* `"earliest_created"`, `"last_modified"`, `"recently_created"` | No |
|
||||
| tag_ids | [ string ] | Filter by tag IDs | No |
|
||||
|
||||
@ -15744,7 +15744,7 @@ AppMCPServer Status Enum
|
||||
| copyright | string | | No |
|
||||
| custom_disclaimer | string | | No |
|
||||
| customize_domain | string | | No |
|
||||
| customize_token_strategy | string | | No |
|
||||
| customize_token_strategy | string, <br>**Available values:** "allow", "must", "not_allow" | | No |
|
||||
| default_language | string | | No |
|
||||
| description | string | | No |
|
||||
| icon | string | | No |
|
||||
@ -16202,7 +16202,7 @@ TEAM: Team collaboration paid plan
|
||||
| files | [ object ] | | No |
|
||||
| inputs | object | | Yes |
|
||||
| query | string | | No |
|
||||
| response_mode | string | | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | | No |
|
||||
| retriever_from | string, <br>**Default:** explore_app | | No |
|
||||
|
||||
#### CompletionMessagePayload
|
||||
@ -16223,7 +16223,7 @@ TEAM: Team collaboration paid plan
|
||||
| files | [ object ] | | No |
|
||||
| inputs | object | | Yes |
|
||||
| query | string | | No |
|
||||
| response_mode | string | | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | | No |
|
||||
| retriever_from | string, <br>**Default:** explore_app | | No |
|
||||
|
||||
#### ComplianceDownloadQuery
|
||||
@ -18257,9 +18257,9 @@ Flask blueprint initialization.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| end_date | string | End date (YYYY-MM-DD) | No |
|
||||
| format | string, <br>**Available values:** "csv", "json", <br>**Default:** csv | Export format<br>*Enum:* `"csv"`, `"json"` | No |
|
||||
| from_source | string | Filter by feedback source | No |
|
||||
| from_source | string, <br>**Available values:** "admin", "user" | Filter by feedback source | No |
|
||||
| has_comment | boolean | Only include feedback with comments | No |
|
||||
| rating | string | Filter by rating | No |
|
||||
| rating | string, <br>**Available values:** "dislike", "like" | Filter by rating | No |
|
||||
| start_date | string | Start date (YYYY-MM-DD) | No |
|
||||
|
||||
#### FeedbackStat
|
||||
@ -18657,7 +18657,7 @@ Icon information model.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
| icon_type | string, <br>**Available values:** "emoji", "image" | | No |
|
||||
| icon_url | string | | No |
|
||||
|
||||
#### IconType
|
||||
@ -19239,7 +19239,7 @@ Enum class for large language model mode.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | Optional text feedback providing additional detail. | No |
|
||||
| message_id | string | Message ID | Yes |
|
||||
| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
| rating | string, <br>**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
|
||||
#### MessageFile
|
||||
|
||||
@ -19300,7 +19300,7 @@ Metadata Filtering Condition.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conditions | [ [Condition](#condition) ] | List of metadata conditions to evaluate. | No |
|
||||
| logical_operator | string | How to combine multiple conditions. | No |
|
||||
| logical_operator | string, <br>**Available values:** "and", "or" | How to combine multiple conditions. | No |
|
||||
|
||||
#### MetadataOperationData
|
||||
|
||||
@ -19433,7 +19433,7 @@ Enum class for model property key.
|
||||
| is_exhausted | boolean | | Yes |
|
||||
| is_unlimited | boolean | | Yes |
|
||||
| next_credit_reset_date | integer | | Yes |
|
||||
| pool_type | string | | Yes |
|
||||
| pool_type | string, <br>**Available values:** "paid", "trial" | | Yes |
|
||||
| quota_limit | integer | Credit limit for the effective pool; -1 means unlimited. | Yes |
|
||||
| quota_used | integer | | Yes |
|
||||
| remaining_credits | integer | Remaining credits; -1 means unlimited. | Yes |
|
||||
@ -21428,7 +21428,7 @@ Model class for provider quota configuration.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| metadata_filtering_conditions | [MetadataFilteringCondition](#metadatafilteringcondition) | Restrict retrieval to chunks whose document metadata matches the given conditions. Conditions are evaluated server-side against document metadata fields. | No |
|
||||
| reranking_enable | boolean | Whether reranking is enabled. | Yes |
|
||||
| reranking_mode | string | Reranking mode. Required when `reranking_enable` is `true`. | No |
|
||||
| reranking_mode | string, <br>**Available values:** "reranking_model", "weighted_score" | Reranking mode. Required when `reranking_enable` is `true`. | No |
|
||||
| reranking_model | [RerankingModel](#rerankingmodel) | Reranking model configuration. | No |
|
||||
| score_threshold | number | Minimum similarity score for results. Only effective when score threshold filtering is enabled. | No |
|
||||
| score_threshold_enabled | boolean | Whether score threshold filtering is enabled. | Yes |
|
||||
@ -21482,7 +21482,7 @@ Model class for provider quota configuration.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| parent_mode | string | Parent-child segmentation mode. | No |
|
||||
| parent_mode | string, <br>**Available values:** "full-doc", "paragraph" | Parent-child segmentation mode. | No |
|
||||
| pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | Pre-processing rules to apply before segmentation. | No |
|
||||
| segmentation | [Segmentation](#segmentation) | Parent chunk segmentation settings. | No |
|
||||
| subchunk_segmentation | [Segmentation](#segmentation) | Child chunk segmentation settings. | No |
|
||||
@ -22492,7 +22492,7 @@ Query parameters for listing snippet published workflows.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| action | string, <br>**Available values:** "complete_task", "disable_current_workspace", "enable_current_workspace", "skip", "uncomplete_task" | State update action<br>*Enum:* `"complete_task"`, `"disable_current_workspace"`, `"enable_current_workspace"`, `"skip"`, `"uncomplete_task"` | Yes |
|
||||
| task_id | string | Task ID for task actions | No |
|
||||
| task_id | string, <br>**Available values:** "home", "integration", "knowledge", "studio" | Task ID for task actions | No |
|
||||
|
||||
#### StepByStepTourStateResponse
|
||||
|
||||
@ -22958,7 +22958,7 @@ Tool label
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| visibility | string | Visibility for the OAuth credential. Defaults to 'only_me'. | No |
|
||||
| visibility | string, <br>**Available values:** "all_team_members", "only_me" | Visibility for the OAuth credential. Defaults to 'only_me'. | No |
|
||||
|
||||
#### ToolOAuthCustomClientPayload
|
||||
|
||||
@ -23090,7 +23090,7 @@ removes TOOLS_SELECTOR from PluginParameterType
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| type | string | | No |
|
||||
| type | string, <br>**Available values:** "api", "builtin", "mcp", "model", "workflow" | | No |
|
||||
|
||||
#### ToolProviderListResponse
|
||||
|
||||
@ -23708,7 +23708,7 @@ in form definition, or a variable while the workflow is running.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | Keyword search weight settings. | No |
|
||||
| vector_setting | [WeightVectorSetting](#weightvectorsetting) | Semantic search weight settings. | No |
|
||||
| weight_type | string | Strategy for balancing semantic and keyword search weights. | No |
|
||||
| weight_type | string, <br>**Available values:** "customized", "keyword_first", "semantic_first" | Strategy for balancing semantic and keyword search weights. | No |
|
||||
|
||||
#### WeightVectorSetting
|
||||
|
||||
@ -24214,7 +24214,7 @@ can reuse its existing handler.
|
||||
| description | string | | No |
|
||||
| event | string | | No |
|
||||
| icon | string | | No |
|
||||
| mode | string | *Enum:* `"advanced-chat"`, `"workflow"` | Yes |
|
||||
| mode | string, <br>**Available values:** "advanced-chat", "workflow" | *Enum:* `"advanced-chat"`, `"workflow"` | Yes |
|
||||
| nodes | [ [WorkflowPlanNodeResponse](#workflowplannoderesponse) ] | | Yes |
|
||||
| start_inputs | [ [WorkflowPlanStartInputResponse](#workflowplanstartinputresponse) ] | | No |
|
||||
| title | string | | No |
|
||||
@ -24229,7 +24229,7 @@ can reuse its existing handler.
|
||||
| graph | [WorkflowGraph](#workflowgraph) | | Yes |
|
||||
| icon | string | | No |
|
||||
| message | string | | No |
|
||||
| mode | string | | No |
|
||||
| mode | string, <br>**Available values:** "advanced-chat", "workflow" | | No |
|
||||
|
||||
#### WorkflowGenerateResultEventResponse
|
||||
|
||||
@ -24242,7 +24242,7 @@ can reuse its existing handler.
|
||||
| graph | [WorkflowGraph](#workflowgraph) | | Yes |
|
||||
| icon | string | | No |
|
||||
| message | string | | No |
|
||||
| mode | string | | No |
|
||||
| mode | string, <br>**Available values:** "advanced-chat", "workflow" | | No |
|
||||
|
||||
#### WorkflowGenerateStreamEventResponse
|
||||
|
||||
@ -24542,9 +24542,9 @@ Lifecycle state for an asynchronous archive download request.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| status | string | Workflow run status filter | No |
|
||||
| status | string, <br>**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | Workflow run status filter | No |
|
||||
| time_range | string | Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), 30m (30 minutes), 30s (30 seconds). Filters by created_at field. | No |
|
||||
| triggered_from | string | Filter by trigger source: debugging or app-run. Default: debugging | No |
|
||||
| triggered_from | string, <br>**Available values:** "app-run", "debugging" | Filter by trigger source: debugging or app-run. Default: debugging | No |
|
||||
|
||||
#### WorkflowRunCountResponse
|
||||
|
||||
@ -24616,8 +24616,8 @@ Lifecycle state for an asynchronous archive download request.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| last_id | string | Last run ID for pagination | No |
|
||||
| limit | integer, <br>**Default:** 20 | Number of items per page (1-100) | No |
|
||||
| status | string | Workflow run status filter | No |
|
||||
| triggered_from | string | Filter by trigger source: debugging or app-run. Default: debugging | No |
|
||||
| status | string, <br>**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | Workflow run status filter | No |
|
||||
| triggered_from | string, <br>**Available values:** "app-run", "debugging" | Filter by trigger source: debugging or app-run. Default: debugging | No |
|
||||
|
||||
#### WorkflowRunNodeExecutionListResponse
|
||||
|
||||
@ -24915,7 +24915,7 @@ Workflow tool configuration
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| language | string | Localized policy label language | No |
|
||||
| language | string, <br>**Available values:** "en", "ja", "zh" | Localized policy label language | No |
|
||||
|
||||
#### _AccessPolicyList
|
||||
|
||||
@ -24974,7 +24974,7 @@ Workflow tool configuration
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| language | string | Localized policy label language | No |
|
||||
| language | string, <br>**Available values:** "en", "ja", "zh" | Localized policy label language | No |
|
||||
| limit | integer | | No |
|
||||
| page | integer | | No |
|
||||
| reverse | boolean | | No |
|
||||
|
||||
@ -2211,7 +2211,7 @@ Execute a workflow. Cannot be executed without a published workflow.
|
||||
| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. | **application/json**: [WorkflowBlockingResponse](#workflowblockingresponse)<br>**text/event-stream**: string<br> |
|
||||
| 400 | - `not_workflow_app` : App mode does not match the API route. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Workflow execution request failed. - `invalid_param` : Invalid parameter value. | |
|
||||
| 401 | Unauthorized - invalid API token | |
|
||||
| 403 | Forbidden - token scope, app, dataset, or workspace access denied | |
|
||||
| 403 | - `forbidden` : Token scope, app, or workspace access denied. - `trigger_workflow_service_mode_unavailable` : Trigger-entry workflows cannot be invoked through Web App, Service API, OpenAPI, or MCP. | |
|
||||
| 404 | Workflow not found | |
|
||||
| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | |
|
||||
| 500 | `internal_server_error` : Internal server error. | |
|
||||
@ -2287,7 +2287,7 @@ Execute a specific workflow version identified by its ID. Useful for running a p
|
||||
| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. | **application/json**: [WorkflowBlockingResponse](#workflowblockingresponse)<br>**text/event-stream**: string<br> |
|
||||
| 400 | - `not_workflow_app` : App mode does not match the API route. - `bad_request` : Workflow is a draft or has an invalid ID format. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Workflow execution request failed. - `invalid_param` : Required parameter missing or invalid. | |
|
||||
| 401 | Unauthorized - invalid API token | |
|
||||
| 403 | `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the current plan. Upgrade to a paid plan. | |
|
||||
| 403 | - `forbidden` : Token scope, app, or workspace access denied. - `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the current plan. Upgrade to a paid plan. - `trigger_workflow_service_mode_unavailable` : The selected workflow version uses a trigger entry and cannot be invoked through Web App, Service API, OpenAPI, or MCP. | |
|
||||
| 404 | `not_found` : Workflow not found. | |
|
||||
| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | |
|
||||
| 500 | `internal_server_error` : Internal server error. | |
|
||||
@ -2587,7 +2587,7 @@ Public pause reason emitted by a blocking Chatflow execution.
|
||||
| files | [ object<br>object<br>object<br>object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
|
||||
| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes |
|
||||
| query | string | User input or question content. | Yes |
|
||||
| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No |
|
||||
| workflow_id | string | Published workflow version ID to execute for advanced chat. If omitted, the app's current published workflow is used. | No |
|
||||
|
||||
#### ChatRequestPayloadWithUser
|
||||
@ -2599,7 +2599,7 @@ Public pause reason emitted by a blocking Chatflow execution.
|
||||
| files | [ object<br>object<br>object<br>object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
|
||||
| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes |
|
||||
| query | string | User input or question content. | Yes |
|
||||
| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No |
|
||||
| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes |
|
||||
| workflow_id | string | Published workflow version ID to execute for advanced chat. If omitted, the app's current published workflow is used. | No |
|
||||
|
||||
@ -2672,7 +2672,7 @@ Public pause reason emitted by a blocking Chatflow execution.
|
||||
| files | [ object<br>object<br>object<br>object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
|
||||
| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes |
|
||||
| query | string | User input or prompt content. | No |
|
||||
| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No |
|
||||
|
||||
#### CompletionRequestPayloadWithUser
|
||||
|
||||
@ -2681,7 +2681,7 @@ Public pause reason emitted by a blocking Chatflow execution.
|
||||
| files | [ object<br>object<br>object<br>object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
|
||||
| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes |
|
||||
| query | string | User input or prompt content. | No |
|
||||
| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No |
|
||||
| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes |
|
||||
|
||||
#### Condition
|
||||
@ -2797,7 +2797,7 @@ Enum class for custom configuration status.
|
||||
| embedding_model_provider | string | Embedding model provider. Use the `provider` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No |
|
||||
| external_knowledge_api_id | string | ID of the external knowledge API. | No |
|
||||
| external_knowledge_id | string | ID of the external knowledge base. | No |
|
||||
| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No |
|
||||
| indexing_technique | string, <br>**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No |
|
||||
| name | string | Name of the knowledge base. | Yes |
|
||||
| permission | [PermissionEnum](#permissionenum) | Controls who can access this knowledge base. `only_me` restricts access to the creator, `all_team_members` grants workspace-wide access, and `partial_members` grants access to specified members. | No |
|
||||
| provider | string, <br>**Available values:** "external", "vendor", <br>**Default:** vendor | Knowledge base provider: `vendor` for internal knowledge bases, `external` for external ones.<br>*Enum:* `"external"`, `"vendor"` | No |
|
||||
@ -3039,7 +3039,7 @@ Enum class for custom configuration status.
|
||||
| external_knowledge_api_id | string | ID of the external knowledge API. | No |
|
||||
| external_knowledge_id | string | ID of the external knowledge base. | No |
|
||||
| external_retrieval_model | object | Retrieval settings for external knowledge bases. | No |
|
||||
| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No |
|
||||
| indexing_technique | string, <br>**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No |
|
||||
| name | string | Name of the knowledge base. | No |
|
||||
| partial_member_list | [ object ] | List of team members with access when `permission` is `partial_members`. | No |
|
||||
| permission | [PermissionEnum](#permissionenum) | Controls who can access this knowledge base. `only_me` restricts access to the creator, `all_team_members` grants workspace-wide access, and `partial_members` grants access to specified members. | No |
|
||||
@ -3167,7 +3167,7 @@ Request payload for bulk downloading documents as a zip archive.
|
||||
| keyword | string | Search keyword to filter by document name. | No |
|
||||
| limit | integer, <br>**Default:** 20 | Number of items per page. Server caps at `100`. | No |
|
||||
| page | integer, <br>**Default:** 1 | Page number to retrieve. | No |
|
||||
| status | string | Filter by display status. | No |
|
||||
| status | string, <br>**Available values:** "archived", "available", "disabled", "error", "indexing", "paused", "queuing" | Filter by display status. | No |
|
||||
|
||||
#### DocumentListResponse
|
||||
|
||||
@ -3265,7 +3265,7 @@ Request payload for bulk downloading documents as a zip archive.
|
||||
| doc_language | string, <br>**Default:** English | Language of the document for processing optimization. | No |
|
||||
| embedding_model | string | Embedding model name. Use the `model` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No |
|
||||
| embedding_model_provider | string | Embedding model provider. Use the `provider` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No |
|
||||
| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. Required when adding the first document to a knowledge base; subsequent documents inherit the knowledge base's indexing technique if omitted. | No |
|
||||
| indexing_technique | string, <br>**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. Required when adding the first document to a knowledge base; subsequent documents inherit the knowledge base's indexing technique if omitted. | No |
|
||||
| name | string | Document name. | Yes |
|
||||
| original_document_id | string | Original document ID for replacement. | No |
|
||||
| process_rule | [ProcessRule](#processrule) | Processing rules for chunking. | No |
|
||||
@ -3614,14 +3614,14 @@ Model class for i18n object.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | Optional text feedback providing additional detail. | No |
|
||||
| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
| rating | string, <br>**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
|
||||
#### MessageFeedbackPayloadWithUser
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | Optional text feedback providing additional detail. | No |
|
||||
| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
| rating | string, <br>**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes |
|
||||
|
||||
#### MessageFile
|
||||
@ -3701,7 +3701,7 @@ Metadata Filtering Condition.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conditions | [ [Condition](#condition) ] | List of metadata conditions to evaluate. | No |
|
||||
| logical_operator | string | How to combine multiple conditions. | No |
|
||||
| logical_operator | string, <br>**Available values:** "and", "or" | How to combine multiple conditions. | No |
|
||||
|
||||
#### MetadataOperationData
|
||||
|
||||
@ -3935,7 +3935,7 @@ Model class for provider with models response.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| metadata_filtering_conditions | [MetadataFilteringCondition](#metadatafilteringcondition) | Restrict retrieval to chunks whose document metadata matches the given conditions. Conditions are evaluated server-side against document metadata fields. | No |
|
||||
| reranking_enable | boolean | Whether reranking is enabled. | Yes |
|
||||
| reranking_mode | string | Reranking mode. Required when `reranking_enable` is `true`. | No |
|
||||
| reranking_mode | string, <br>**Available values:** "reranking_model", "weighted_score" | Reranking mode. Required when `reranking_enable` is `true`. | No |
|
||||
| reranking_model | [RerankingModel](#rerankingmodel) | Reranking model configuration. | No |
|
||||
| score_threshold | number | Minimum similarity score for results. Only effective when score threshold filtering is enabled. | No |
|
||||
| score_threshold_enabled | boolean | Whether score threshold filtering is enabled. | Yes |
|
||||
@ -3969,7 +3969,7 @@ Model class for provider with models response.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| parent_mode | string | Parent-child segmentation mode. | No |
|
||||
| parent_mode | string, <br>**Available values:** "full-doc", "paragraph" | Parent-child segmentation mode. | No |
|
||||
| pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | Pre-processing rules to apply before segmentation. | No |
|
||||
| segmentation | [Segmentation](#segmentation) | Parent chunk segmentation settings. | No |
|
||||
| subchunk_segmentation | [Segmentation](#segmentation) | Child chunk segmentation settings. | No |
|
||||
@ -4300,7 +4300,7 @@ in form definition, or a variable while the workflow is running.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | Keyword search weight settings. | No |
|
||||
| vector_setting | [WeightVectorSetting](#weightvectorsetting) | Semantic search weight settings. | No |
|
||||
| weight_type | string | Strategy for balancing semantic and keyword search weights. | No |
|
||||
| weight_type | string, <br>**Available values:** "customized", "keyword_first", "semantic_first" | Strategy for balancing semantic and keyword search weights. | No |
|
||||
|
||||
#### WeightVectorSetting
|
||||
|
||||
@ -4383,7 +4383,7 @@ Blocking workflow response for a finished or paused execution.
|
||||
| keyword | string | Keyword to search in logs. | No |
|
||||
| limit | integer, <br>**Default:** 20 | Number of items per page. | No |
|
||||
| page | integer, <br>**Default:** 1 | Page number for pagination. | No |
|
||||
| status | string | Filter by execution status. | No |
|
||||
| status | string, <br>**Available values:** "failed", "stopped", "succeeded" | Filter by execution status. | No |
|
||||
|
||||
#### WorkflowPauseReasonResponse
|
||||
|
||||
@ -4452,7 +4452,7 @@ Public pause reason emitted by a blocking Workflow execution.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| files | [ object<br>object<br>object<br>object ] | File list for workflow system file inputs. Available when file upload is enabled for the workflow. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
|
||||
| inputs | object | Key-value pairs for workflow input variables. Values for file-type variables should be arrays of file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover the variable names and types expected by your app. | Yes |
|
||||
| response_mode | string | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No |
|
||||
|
||||
#### WorkflowRunPayloadWithUser
|
||||
|
||||
@ -4460,7 +4460,7 @@ Public pause reason emitted by a blocking Workflow execution.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| files | [ object<br>object<br>object<br>object ] | File list for workflow system file inputs. Available when file upload is enabled for the workflow. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
|
||||
| inputs | object | Key-value pairs for workflow input variables. Values for file-type variables should be arrays of file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover the variable names and types expected by your app. | Yes |
|
||||
| response_mode | string | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No |
|
||||
| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes |
|
||||
|
||||
#### WorkflowRunResponse
|
||||
|
||||
@ -1019,7 +1019,7 @@ Button styles for user actions.
|
||||
| inputs | object | Input variables for the chat | Yes |
|
||||
| parent_message_id | string | Parent message ID | No |
|
||||
| query | string | User query/message | Yes |
|
||||
| response_mode | string | Response mode: blocking or streaming | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode: blocking or streaming | No |
|
||||
| retriever_from | string, <br>**Default:** web_app | Source of retriever | No |
|
||||
|
||||
#### CompletionMessagePayload
|
||||
@ -1029,7 +1029,7 @@ Button styles for user actions.
|
||||
| files | [ object ] | Files to be processed | No |
|
||||
| inputs | object | Input variables for the completion | Yes |
|
||||
| query | string | Query text for completion | No |
|
||||
| response_mode | string | Response mode: blocking or streaming | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode: blocking or streaming | No |
|
||||
| retriever_from | string, <br>**Default:** web_app | Source of retriever | No |
|
||||
|
||||
#### ConversationInfiniteScrollPagination
|
||||
@ -1322,7 +1322,7 @@ Parsed multipart form fields for HITL uploads.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | Optional text feedback providing additional detail. | No |
|
||||
| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
| rating | string, <br>**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
|
||||
#### MessageFile
|
||||
|
||||
|
||||
@ -31,6 +31,14 @@ class SQLAlchemyAccountRepository(AccountRepository):
|
||||
account = session.get(Account, account_id)
|
||||
return self._to_snapshot(account) if account is not None else None
|
||||
|
||||
@override
|
||||
def find_by_email(self, email: str) -> AccountSnapshot | None:
|
||||
with self._session_factory() as session:
|
||||
account = session.scalar(select(Account).where(Account.email == email).limit(1))
|
||||
if account is None and email != email.lower():
|
||||
account = session.scalar(select(Account).where(Account.email == email.lower()).limit(1))
|
||||
return self._to_snapshot(account) if account is not None else None
|
||||
|
||||
@override
|
||||
def get_credentials(self, account_id: str) -> AccountCredentials | None:
|
||||
with self._session_factory() as session:
|
||||
|
||||
189
api/repositories/step_by_step_tour_repository.py
Normal file
189
api/repositories/step_by_step_tour_repository.py
Normal file
@ -0,0 +1,189 @@
|
||||
"""SQLAlchemy repository for account Step-by-step Tour state."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol, override, runtime_checkable
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.onboarding import AccountStepByStepTourState
|
||||
from services.entities.onboarding_entities import StepByStepTourState
|
||||
from services.step_by_step_tour_service import StepByStepTourStateRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MYSQL_RETRYABLE_LOCK_ERRNOS = frozenset({1205, 1213})
|
||||
_MAX_LOCK_ATTEMPTS = 3
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _ErrorWithErrno(Protocol):
|
||||
@property
|
||||
def errno(self) -> object: ...
|
||||
|
||||
|
||||
class SQLAlchemyStepByStepTourStateRepository(StepByStepTourStateRepository):
|
||||
def __init__(self, session_factory: sessionmaker[Session]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
@override
|
||||
def get(self, account_id: str) -> StepByStepTourState | None:
|
||||
with self._session_factory() as session:
|
||||
model = self._get_model(account_id, session=session)
|
||||
return self._to_state(model) if model is not None else None
|
||||
|
||||
@override
|
||||
def initialize(self, account_id: str, first_workspace_id: str) -> StepByStepTourState:
|
||||
"""Create state with its first workspace, or atomically claim a legacy empty state."""
|
||||
return self._run_with_lock_retry(
|
||||
lambda: self._initialize_once(account_id, first_workspace_id),
|
||||
)
|
||||
|
||||
def _initialize_once(self, account_id: str, first_workspace_id: str) -> StepByStepTourState:
|
||||
with self._session_factory() as session:
|
||||
model = self._get_model(account_id, session=session)
|
||||
if model is None:
|
||||
model = AccountStepByStepTourState(
|
||||
account_id=account_id,
|
||||
first_workspace_id=first_workspace_id,
|
||||
)
|
||||
session.add(model)
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
# A concurrent request inserted the account-owned row first.
|
||||
session.rollback()
|
||||
model = self._get_model(account_id, session=session)
|
||||
if model is None:
|
||||
raise
|
||||
else:
|
||||
session.refresh(model)
|
||||
return self._to_state(model)
|
||||
|
||||
if model.first_workspace_id is None:
|
||||
stmt = (
|
||||
update(AccountStepByStepTourState)
|
||||
.where(
|
||||
AccountStepByStepTourState.account_id == account_id,
|
||||
AccountStepByStepTourState.first_workspace_id.is_(None),
|
||||
)
|
||||
.values(first_workspace_id=first_workspace_id)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
# A competing conditional update may have won while this request waited.
|
||||
session.refresh(model)
|
||||
|
||||
return self._to_state(model)
|
||||
|
||||
@override
|
||||
def mutate(
|
||||
self,
|
||||
account_id: str,
|
||||
mutation: Callable[[StepByStepTourState], StepByStepTourState],
|
||||
) -> StepByStepTourState:
|
||||
"""Lock, create if needed, mutate, and persist account state in one transaction."""
|
||||
return self._run_with_lock_retry(
|
||||
lambda: self._mutate_once(account_id, mutation),
|
||||
)
|
||||
|
||||
def _mutate_once(
|
||||
self,
|
||||
account_id: str,
|
||||
mutation: Callable[[StepByStepTourState], StepByStepTourState],
|
||||
) -> StepByStepTourState:
|
||||
with self._session_factory() as session:
|
||||
# Probe without a locking read so a missing MySQL unique key does not
|
||||
# acquire a gap/next-key lock before the insert.
|
||||
model = self._get_model(account_id, session=session)
|
||||
if model is None:
|
||||
model = AccountStepByStepTourState(account_id=account_id)
|
||||
session.add(model)
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError:
|
||||
# A concurrent mutation created the row. Start a new transaction,
|
||||
# lock its committed state, and replay the pure mutation on it.
|
||||
session.rollback()
|
||||
model = self._get_model(account_id, session=session, lock_for_update=True)
|
||||
if model is None:
|
||||
raise
|
||||
else:
|
||||
model = self._get_model(account_id, session=session, lock_for_update=True)
|
||||
if model is None:
|
||||
raise RuntimeError("Step-by-step Tour state disappeared while acquiring its lock")
|
||||
|
||||
state = mutation(self._to_state(model))
|
||||
if state.account_id != account_id:
|
||||
raise ValueError("Step-by-step Tour mutation cannot change account ownership")
|
||||
# first_workspace_id is write-once and owned exclusively by initialize().
|
||||
model.skipped = state.skipped
|
||||
model.completed_task_ids = list(state.completed_task_ids)
|
||||
model.manually_enabled_workspace_ids = list(state.manually_enabled_workspace_ids)
|
||||
model.manually_disabled_workspace_ids = list(state.manually_disabled_workspace_ids)
|
||||
session.commit()
|
||||
session.refresh(model)
|
||||
return self._to_state(model)
|
||||
|
||||
@staticmethod
|
||||
def _run_with_lock_retry[T](operation: Callable[[], T]) -> T:
|
||||
for attempt in range(1, _MAX_LOCK_ATTEMPTS):
|
||||
try:
|
||||
return operation()
|
||||
except OperationalError as exc:
|
||||
if not _is_retryable_mysql_lock_error(exc):
|
||||
raise
|
||||
logger.warning(
|
||||
"Retrying Step-by-step Tour transaction after MySQL lock failure (attempt %s/%s)",
|
||||
attempt,
|
||||
_MAX_LOCK_ATTEMPTS,
|
||||
)
|
||||
return operation()
|
||||
|
||||
@staticmethod
|
||||
def _get_model(
|
||||
account_id: str,
|
||||
*,
|
||||
session: Session,
|
||||
lock_for_update: bool = False,
|
||||
) -> AccountStepByStepTourState | None:
|
||||
stmt = select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == account_id).limit(1)
|
||||
if lock_for_update:
|
||||
stmt = stmt.with_for_update().execution_options(populate_existing=True)
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
def _to_state(model: AccountStepByStepTourState) -> StepByStepTourState:
|
||||
return StepByStepTourState(
|
||||
account_id=model.account_id,
|
||||
first_workspace_id=model.first_workspace_id,
|
||||
skipped=model.skipped,
|
||||
completed_task_ids=tuple(model.completed_task_ids),
|
||||
manually_enabled_workspace_ids=tuple(model.manually_enabled_workspace_ids),
|
||||
manually_disabled_workspace_ids=tuple(model.manually_disabled_workspace_ids),
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _is_retryable_mysql_lock_error(exc: OperationalError) -> bool:
|
||||
orig = exc.orig
|
||||
if isinstance(orig, _ErrorWithErrno) and _is_retryable_mysql_lock_error_code(orig.errno):
|
||||
return True
|
||||
if not isinstance(orig, BaseException) or not orig.args:
|
||||
return False
|
||||
return _is_retryable_mysql_lock_error_code(orig.args[0])
|
||||
|
||||
|
||||
def _is_retryable_mysql_lock_error_code(candidate: object) -> bool:
|
||||
if isinstance(candidate, bool):
|
||||
return False
|
||||
if isinstance(candidate, int):
|
||||
code = candidate
|
||||
elif isinstance(candidate, str) and candidate.isdecimal():
|
||||
code = int(candidate)
|
||||
else:
|
||||
return False
|
||||
return code in _MYSQL_RETRYABLE_LOCK_ERRNOS
|
||||
230
api/services/account_email_registration_adapters.py
Normal file
230
api/services/account_email_registration_adapters.py
Normal file
@ -0,0 +1,230 @@
|
||||
"""Infrastructure adapters for account email registration."""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from typing import override
|
||||
|
||||
from redis import RedisError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
from libs.helper import RateLimiter, TokenManager
|
||||
from models.account import Account
|
||||
from services.account_email_registration_service import (
|
||||
AccountRegistrationGateway,
|
||||
AccountRegistrationPolicyGateway,
|
||||
EmailRegistrationCodeGenerator,
|
||||
EmailRegistrationNotificationGateway,
|
||||
EmailRegistrationSecurityGateway,
|
||||
EmailRegistrationSendLimiter,
|
||||
EmailRegistrationTokenGateway,
|
||||
)
|
||||
from services.account_errors import (
|
||||
AccountEmailDomainSuspendedError,
|
||||
AccountEmailFrozenError,
|
||||
AccountNormalizedEmailAlreadyInUseError,
|
||||
EmailRegistrationSeatsLimitError,
|
||||
)
|
||||
from services.account_service import AccountService
|
||||
from services.billing_service import BillingService
|
||||
from services.entities.account_entities import (
|
||||
AccountEmailRegistrationPhase,
|
||||
AccountEmailRegistrationToken,
|
||||
AccountSessionTokens,
|
||||
)
|
||||
from services.errors.account import (
|
||||
AccountNormalizedEmailAlreadyInUseError as AccountNormalizedEmailAlreadyInUseServiceError,
|
||||
)
|
||||
from services.errors.account import AccountRegisterError, EmailDomainSuspendedError, SeatsLimitExceededError
|
||||
from tasks.mail_register_task import send_email_register_mail_task, send_email_register_mail_task_when_account_exist
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TokenManagerEmailRegistrationTokenGateway(EmailRegistrationTokenGateway):
|
||||
@override
|
||||
def get(self, token: str) -> AccountEmailRegistrationToken | None:
|
||||
payload = TokenManager.get_token_data(token, "email_register")
|
||||
if payload is None:
|
||||
return None
|
||||
email = payload.get("email")
|
||||
code = payload.get("code")
|
||||
phase_value = payload.get("phase")
|
||||
if not isinstance(email, str) or not isinstance(code, str):
|
||||
return None
|
||||
if phase_value is None:
|
||||
phase = None
|
||||
else:
|
||||
try:
|
||||
phase = AccountEmailRegistrationPhase(phase_value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return AccountEmailRegistrationToken(email=email, code=code, phase=phase)
|
||||
|
||||
@override
|
||||
def issue(self, token_data: AccountEmailRegistrationToken) -> str:
|
||||
additional_data = {"code": token_data.code}
|
||||
if token_data.phase is not None:
|
||||
additional_data["phase"] = token_data.phase.value
|
||||
return TokenManager.generate_token(
|
||||
email=token_data.email,
|
||||
token_type="email_register",
|
||||
additional_data=additional_data,
|
||||
)
|
||||
|
||||
@override
|
||||
def revoke(self, token: str) -> None:
|
||||
TokenManager.revoke_token(token, "email_register")
|
||||
|
||||
|
||||
class SecureEmailRegistrationCodeGenerator(EmailRegistrationCodeGenerator):
|
||||
@override
|
||||
def generate(self) -> str:
|
||||
return "".join(str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6))
|
||||
|
||||
|
||||
class CeleryEmailRegistrationNotificationGateway(EmailRegistrationNotificationGateway):
|
||||
@override
|
||||
def send_code(self, *, email: str, code: str, language: str) -> None:
|
||||
send_email_register_mail_task.delay(language=language, to=email, code=code)
|
||||
|
||||
@override
|
||||
def send_account_exists(self, *, email: str, account_name: str, language: str) -> None:
|
||||
send_email_register_mail_task_when_account_exist.delay(
|
||||
language=language,
|
||||
to=email,
|
||||
account_name=account_name,
|
||||
)
|
||||
|
||||
|
||||
class RateLimiterEmailRegistrationSendLimiter(EmailRegistrationSendLimiter):
|
||||
def __init__(self, *, rate_limiter: RateLimiter) -> None:
|
||||
self._rate_limiter = rate_limiter
|
||||
|
||||
@override
|
||||
def is_limited(self, email: str) -> bool:
|
||||
return self._rate_limiter.is_rate_limited(email)
|
||||
|
||||
@override
|
||||
def record(self, email: str) -> None:
|
||||
self._rate_limiter.increment_rate_limit(email)
|
||||
|
||||
@property
|
||||
@override
|
||||
def retry_after_minutes(self) -> int:
|
||||
return int(self._rate_limiter.time_window / 60)
|
||||
|
||||
|
||||
class RedisEmailRegistrationSecurityGateway(EmailRegistrationSecurityGateway):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
redis: RedisClientWrapper,
|
||||
verification_failure_limit: int,
|
||||
verification_lockout_duration: int,
|
||||
) -> None:
|
||||
self._redis = redis
|
||||
self._verification_failure_limit = verification_failure_limit
|
||||
self._verification_lockout_duration = verification_lockout_duration
|
||||
|
||||
@override
|
||||
def is_ip_limited(self, ip_address: str) -> bool:
|
||||
return AccountService.is_email_send_ip_limit(ip_address) is True
|
||||
|
||||
@override
|
||||
def is_verification_limited(self, email: str) -> bool:
|
||||
try:
|
||||
count = self._redis.get(self._verification_key(email))
|
||||
return count is not None and int(count) > self._verification_failure_limit
|
||||
except RedisError:
|
||||
logger.warning("Failed to read email-registration verification limit", exc_info=True)
|
||||
return False
|
||||
|
||||
@override
|
||||
def record_verification_failure(self, email: str) -> None:
|
||||
try:
|
||||
key = self._verification_key(email)
|
||||
count = int(self._redis.get(key) or 0) + 1
|
||||
self._redis.setex(key, self._verification_lockout_duration, count)
|
||||
except RedisError:
|
||||
logger.warning("Failed to record email-registration verification failure", exc_info=True)
|
||||
return None
|
||||
|
||||
@override
|
||||
def reset_verification_failures(self, email: str) -> None:
|
||||
try:
|
||||
self._redis.delete(self._verification_key(email))
|
||||
except RedisError:
|
||||
logger.warning("Failed to reset email-registration verification failures", exc_info=True)
|
||||
return None
|
||||
|
||||
@override
|
||||
def reset_login_failures(self, email: str) -> None:
|
||||
AccountService.reset_login_error_rate_limit(email)
|
||||
|
||||
@staticmethod
|
||||
def _verification_key(email: str) -> str:
|
||||
return f"email_register_error_rate_limit:{email}"
|
||||
|
||||
|
||||
class BillingAccountRegistrationPolicyGateway(AccountRegistrationPolicyGateway):
|
||||
def __init__(self, *, enabled: bool) -> None:
|
||||
self._enabled = enabled
|
||||
|
||||
@override
|
||||
def get_freeze_type(self, email: str) -> str | None:
|
||||
if not self._enabled:
|
||||
return None
|
||||
return BillingService.get_email_freeze_type(email)
|
||||
|
||||
|
||||
class AccountServiceRegistrationGateway(AccountRegistrationGateway):
|
||||
"""Compatibility adapter around account provisioning and login internals."""
|
||||
|
||||
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
@override
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
interface_language: str,
|
||||
timezone: str | None,
|
||||
ip_address: str,
|
||||
) -> str:
|
||||
with self._session_factory() as session:
|
||||
try:
|
||||
account = AccountService.create_account_and_tenant(
|
||||
email=email,
|
||||
name=email,
|
||||
password=password,
|
||||
interface_language=interface_language,
|
||||
timezone=timezone,
|
||||
ip_address=ip_address,
|
||||
check_normalized_email=True,
|
||||
session=session,
|
||||
)
|
||||
except SeatsLimitExceededError as exc:
|
||||
raise EmailRegistrationSeatsLimitError from exc
|
||||
except EmailDomainSuspendedError as exc:
|
||||
raise AccountEmailDomainSuspendedError from exc
|
||||
except AccountNormalizedEmailAlreadyInUseServiceError as exc:
|
||||
raise AccountNormalizedEmailAlreadyInUseError from exc
|
||||
except AccountRegisterError as exc:
|
||||
raise AccountEmailFrozenError from exc
|
||||
return account.id
|
||||
|
||||
@override
|
||||
def login(self, account_id: str, *, ip_address: str) -> AccountSessionTokens:
|
||||
with self._session_factory() as session:
|
||||
account = session.get(Account, account_id)
|
||||
if account is None:
|
||||
raise RuntimeError("newly registered account no longer exists")
|
||||
token_pair = AccountService.login(account=account, session=session, ip_address=ip_address)
|
||||
return AccountSessionTokens(
|
||||
access_token=token_pair.access_token,
|
||||
refresh_token=token_pair.refresh_token,
|
||||
csrf_token=token_pair.csrf_token,
|
||||
)
|
||||
207
api/services/account_email_registration_service.py
Normal file
207
api/services/account_email_registration_service.py
Normal file
@ -0,0 +1,207 @@
|
||||
"""Application service for the account email-registration use case."""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from constants.languages import get_valid_language, languages
|
||||
from services.account_errors import (
|
||||
AccountEmailAlreadyInUseError,
|
||||
AccountEmailDomainSuspendedError,
|
||||
AccountEmailFrozenError,
|
||||
EmailRegistrationPasswordMismatchError,
|
||||
EmailRegistrationSendIPLimitedError,
|
||||
EmailRegistrationSendRateLimitError,
|
||||
EmailRegistrationVerificationLimitError,
|
||||
InvalidEmailRegistrationAddressError,
|
||||
InvalidEmailRegistrationCodeError,
|
||||
InvalidEmailRegistrationTokenError,
|
||||
)
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.account_entities import (
|
||||
AccountEmailRegistrationPhase,
|
||||
AccountEmailRegistrationToken,
|
||||
AccountEmailRegistrationVerification,
|
||||
AccountSessionTokens,
|
||||
)
|
||||
|
||||
|
||||
class EmailRegistrationTokenGateway(Protocol):
|
||||
def get(self, token: str) -> AccountEmailRegistrationToken | None: ...
|
||||
|
||||
def issue(self, token_data: AccountEmailRegistrationToken) -> str: ...
|
||||
|
||||
def revoke(self, token: str) -> None: ...
|
||||
|
||||
|
||||
class EmailRegistrationCodeGenerator(Protocol):
|
||||
def generate(self) -> str: ...
|
||||
|
||||
|
||||
class EmailRegistrationNotificationGateway(Protocol):
|
||||
def send_code(self, *, email: str, code: str, language: str) -> None: ...
|
||||
|
||||
def send_account_exists(self, *, email: str, account_name: str, language: str) -> None: ...
|
||||
|
||||
|
||||
class EmailRegistrationSendLimiter(Protocol):
|
||||
def is_limited(self, email: str) -> bool: ...
|
||||
|
||||
def record(self, email: str) -> None: ...
|
||||
|
||||
@property
|
||||
def retry_after_minutes(self) -> int: ...
|
||||
|
||||
|
||||
class EmailRegistrationSecurityGateway(Protocol):
|
||||
def is_ip_limited(self, ip_address: str) -> bool: ...
|
||||
|
||||
def is_verification_limited(self, email: str) -> bool: ...
|
||||
|
||||
def record_verification_failure(self, email: str) -> None: ...
|
||||
|
||||
def reset_verification_failures(self, email: str) -> None: ...
|
||||
|
||||
def reset_login_failures(self, email: str) -> None: ...
|
||||
|
||||
|
||||
class AccountRegistrationPolicyGateway(Protocol):
|
||||
def get_freeze_type(self, email: str) -> str | None: ...
|
||||
|
||||
|
||||
class AccountRegistrationGateway(Protocol):
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
interface_language: str,
|
||||
timezone: str | None,
|
||||
ip_address: str,
|
||||
) -> str: ...
|
||||
|
||||
def login(self, account_id: str, *, ip_address: str) -> AccountSessionTokens: ...
|
||||
|
||||
|
||||
class AccountEmailRegistrationService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
accounts: AccountRepository,
|
||||
tokens: EmailRegistrationTokenGateway,
|
||||
codes: EmailRegistrationCodeGenerator,
|
||||
notifications: EmailRegistrationNotificationGateway,
|
||||
send_limits: EmailRegistrationSendLimiter,
|
||||
security: EmailRegistrationSecurityGateway,
|
||||
account_policy: AccountRegistrationPolicyGateway,
|
||||
registration: AccountRegistrationGateway,
|
||||
) -> None:
|
||||
self._accounts = accounts
|
||||
self._tokens = tokens
|
||||
self._codes = codes
|
||||
self._notifications = notifications
|
||||
self._send_limits = send_limits
|
||||
self._security = security
|
||||
self._account_policy = account_policy
|
||||
self._registration = registration
|
||||
|
||||
def send_code(
|
||||
self,
|
||||
*,
|
||||
remote_ip: str,
|
||||
requested_email: str,
|
||||
requested_language: str | None,
|
||||
) -> str:
|
||||
if self._security.is_ip_limited(remote_ip):
|
||||
raise EmailRegistrationSendIPLimitedError
|
||||
|
||||
normalized_email = requested_email.lower()
|
||||
self._ensure_email_allowed(normalized_email)
|
||||
account = self._accounts.find_by_email(requested_email)
|
||||
delivery_email = account.email if account is not None else normalized_email
|
||||
if self._send_limits.is_limited(delivery_email):
|
||||
raise EmailRegistrationSendRateLimitError(self._send_limits.retry_after_minutes)
|
||||
|
||||
language = requested_language if requested_language is not None and requested_language in languages else "en-US"
|
||||
code = self._codes.generate()
|
||||
token = self._tokens.issue(AccountEmailRegistrationToken(email=delivery_email, code=code))
|
||||
if account is None:
|
||||
self._notifications.send_code(email=delivery_email, code=code, language=language)
|
||||
else:
|
||||
self._notifications.send_account_exists(
|
||||
email=delivery_email,
|
||||
account_name=account.name,
|
||||
language=language,
|
||||
)
|
||||
self._send_limits.record(delivery_email)
|
||||
return token
|
||||
|
||||
def verify_code(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
code: str,
|
||||
token: str,
|
||||
) -> AccountEmailRegistrationVerification:
|
||||
normalized_email = email.lower()
|
||||
if self._security.is_verification_limited(normalized_email):
|
||||
raise EmailRegistrationVerificationLimitError
|
||||
|
||||
token_data = self._tokens.get(token)
|
||||
if token_data is None:
|
||||
raise InvalidEmailRegistrationTokenError
|
||||
normalized_token_email = token_data.email.lower()
|
||||
if normalized_email != normalized_token_email:
|
||||
raise InvalidEmailRegistrationAddressError
|
||||
if code != token_data.code:
|
||||
self._security.record_verification_failure(normalized_email)
|
||||
raise InvalidEmailRegistrationCodeError
|
||||
|
||||
self._tokens.revoke(token)
|
||||
verified_token = self._tokens.issue(
|
||||
AccountEmailRegistrationToken(
|
||||
email=normalized_email,
|
||||
code=code,
|
||||
phase=AccountEmailRegistrationPhase.REGISTER,
|
||||
)
|
||||
)
|
||||
self._security.reset_verification_failures(normalized_email)
|
||||
return AccountEmailRegistrationVerification(email=normalized_token_email, token=verified_token)
|
||||
|
||||
def register(
|
||||
self,
|
||||
*,
|
||||
remote_ip: str,
|
||||
token: str,
|
||||
new_password: str,
|
||||
password_confirm: str,
|
||||
language: str | None,
|
||||
timezone: str | None,
|
||||
) -> AccountSessionTokens:
|
||||
if new_password != password_confirm:
|
||||
raise EmailRegistrationPasswordMismatchError
|
||||
|
||||
token_data = self._tokens.get(token)
|
||||
if token_data is None or token_data.phase != AccountEmailRegistrationPhase.REGISTER:
|
||||
raise InvalidEmailRegistrationTokenError
|
||||
self._tokens.revoke(token)
|
||||
|
||||
normalized_email = token_data.email.lower()
|
||||
if self._accounts.find_by_email(token_data.email) is not None:
|
||||
raise AccountEmailAlreadyInUseError
|
||||
|
||||
account_id = self._registration.create(
|
||||
email=normalized_email,
|
||||
password=password_confirm,
|
||||
interface_language=get_valid_language(language),
|
||||
timezone=timezone,
|
||||
ip_address=remote_ip,
|
||||
)
|
||||
tokens = self._registration.login(account_id, ip_address=remote_ip)
|
||||
self._security.reset_login_failures(normalized_email)
|
||||
return tokens
|
||||
|
||||
def _ensure_email_allowed(self, email: str) -> None:
|
||||
freeze_type = self._account_policy.get_freeze_type(email)
|
||||
if freeze_type == "email_domain_suspended":
|
||||
raise AccountEmailDomainSuspendedError
|
||||
if freeze_type:
|
||||
raise AccountEmailFrozenError
|
||||
@ -85,6 +85,46 @@ class AccountEmailAlreadyInUseError(AccountApplicationError):
|
||||
"""The target email already belongs to an account."""
|
||||
|
||||
|
||||
class AccountNormalizedEmailAlreadyInUseError(AccountEmailAlreadyInUseError):
|
||||
"""A normalized equivalent of the target email already belongs to an account."""
|
||||
|
||||
|
||||
class EmailRegistrationSendIPLimitedError(AccountApplicationError):
|
||||
"""The caller IP exceeded the registration-email send policy."""
|
||||
|
||||
|
||||
class EmailRegistrationSendRateLimitError(AccountApplicationError):
|
||||
"""Too many registration messages were requested for the address."""
|
||||
|
||||
def __init__(self, retry_after_minutes: int) -> None:
|
||||
super().__init__(retry_after_minutes)
|
||||
self.retry_after_minutes = retry_after_minutes
|
||||
|
||||
|
||||
class EmailRegistrationVerificationLimitError(AccountApplicationError):
|
||||
"""Too many invalid registration-code attempts were made."""
|
||||
|
||||
|
||||
class InvalidEmailRegistrationTokenError(AccountApplicationError):
|
||||
"""The registration token is absent, malformed, or in the wrong phase."""
|
||||
|
||||
|
||||
class InvalidEmailRegistrationAddressError(AccountApplicationError):
|
||||
"""The request address does not match the registration token."""
|
||||
|
||||
|
||||
class InvalidEmailRegistrationCodeError(AccountApplicationError):
|
||||
"""The verification code does not match the registration token."""
|
||||
|
||||
|
||||
class EmailRegistrationPasswordMismatchError(AccountApplicationError):
|
||||
"""The registration password confirmation does not match."""
|
||||
|
||||
|
||||
class EmailRegistrationSeatsLimitError(AccountApplicationError):
|
||||
"""The deployment has no licensed seat available for another account."""
|
||||
|
||||
|
||||
class EducationDiscountPausedError(AccountApplicationError):
|
||||
"""Education discount activation is temporarily paused."""
|
||||
|
||||
|
||||
@ -19,6 +19,8 @@ from services.entities.account_entities import (
|
||||
class AccountRepository(Protocol):
|
||||
def get(self, account_id: str) -> AccountSnapshot | None: ...
|
||||
|
||||
def find_by_email(self, email: str) -> AccountSnapshot | None: ...
|
||||
|
||||
def get_credentials(self, account_id: str) -> AccountCredentials | None: ...
|
||||
|
||||
def update_profile(self, account_id: str, changes: AccountProfileChanges) -> AccountSnapshot | None: ...
|
||||
|
||||
@ -93,7 +93,6 @@ from tasks.mail_owner_transfer_task import (
|
||||
send_old_owner_transfer_notify_email_task,
|
||||
send_owner_transfer_confirm_task,
|
||||
)
|
||||
from tasks.mail_register_task import send_email_register_mail_task, send_email_register_mail_task_when_account_exist
|
||||
from tasks.mail_reset_password_task import (
|
||||
send_reset_password_mail_task,
|
||||
send_reset_password_mail_task_when_account_not_exist,
|
||||
@ -157,7 +156,6 @@ class AccountService:
|
||||
CHANGE_EMAIL_PHASE_NEW = ChangeEmailPhase.NEW_EMAIL
|
||||
|
||||
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
|
||||
)
|
||||
@ -168,7 +166,16 @@ class AccountService:
|
||||
FORGOT_PASSWORD_MAX_ERROR_LIMITS = 5
|
||||
CHANGE_EMAIL_MAX_ERROR_LIMITS = 5
|
||||
OWNER_TRANSFER_MAX_ERROR_LIMITS = 5
|
||||
EMAIL_REGISTER_MAX_ERROR_LIMITS = 5
|
||||
|
||||
@staticmethod
|
||||
def _resolve_role_id_by_tag(tenant_id: str, account_id: str, tag: str) -> str:
|
||||
options = ListOption(page_number=1, results_per_page=100)
|
||||
roles = RBACService.Roles.list(tenant_id, account_id, options=options).data
|
||||
for rbac_role in roles:
|
||||
if rbac_role.is_builtin and rbac_role.category == "global_system_default" and rbac_role.role_tag == tag:
|
||||
return str(rbac_role.id)
|
||||
|
||||
raise ValueError(f"Builtin RBAC role not found for tag {tag!r} in tenant {tenant_id}")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_legacy_role_id(tenant_id: str, account_id: str, role: TenantAccountRole) -> str:
|
||||
@ -177,9 +184,6 @@ class AccountService:
|
||||
Looks up the builtin RBAC role whose tag matches the legacy role name
|
||||
(e.g. ``TenantAccountRole.ADMIN`` → builtin role with tag ``"admin"``).
|
||||
"""
|
||||
options = ListOption(page_number=1, results_per_page=100)
|
||||
roles = RBACService.Roles.list(tenant_id, account_id, options=options).data
|
||||
|
||||
expected_tag = {
|
||||
TenantAccountRole.OWNER: "owner",
|
||||
TenantAccountRole.ADMIN: "admin",
|
||||
@ -187,15 +191,7 @@ class AccountService:
|
||||
TenantAccountRole.NORMAL: "normal",
|
||||
TenantAccountRole.DATASET_OPERATOR: "dataset_operator",
|
||||
}[role]
|
||||
for rbac_role in roles:
|
||||
if (
|
||||
rbac_role.is_builtin
|
||||
and rbac_role.category == "global_system_default"
|
||||
and rbac_role.role_tag == expected_tag
|
||||
):
|
||||
return str(rbac_role.id)
|
||||
|
||||
raise ValueError(f"Builtin RBAC role not found for {role.value} in tenant {tenant_id}")
|
||||
return AccountService._resolve_role_id_by_tag(tenant_id, account_id, expected_tag)
|
||||
|
||||
@staticmethod
|
||||
def get_workspace_permission_keys(tenant_id: str, account_id: str, *, session: Session) -> set[str]:
|
||||
@ -680,40 +676,6 @@ class AccountService:
|
||||
cls.reset_password_rate_limiter.increment_rate_limit(account_email)
|
||||
return token
|
||||
|
||||
@classmethod
|
||||
def send_email_register_email(
|
||||
cls,
|
||||
account: Account | None = None,
|
||||
email: str | None = None,
|
||||
language: str = "en-US",
|
||||
):
|
||||
account_email = account.email if account else email
|
||||
if account_email is None:
|
||||
raise ValueError("Email must be provided.")
|
||||
|
||||
if cls.email_register_rate_limiter.is_rate_limited(account_email):
|
||||
from controllers.console.auth.error import EmailRegisterRateLimitExceededError
|
||||
|
||||
raise EmailRegisterRateLimitExceededError(int(cls.email_register_rate_limiter.time_window / 60))
|
||||
|
||||
code, token = cls.generate_email_register_token(account_email)
|
||||
|
||||
if account:
|
||||
send_email_register_mail_task_when_account_exist.delay(
|
||||
language=language,
|
||||
to=account_email,
|
||||
account_name=account.name,
|
||||
)
|
||||
|
||||
else:
|
||||
send_email_register_mail_task.delay(
|
||||
language=language,
|
||||
to=account_email,
|
||||
code=code,
|
||||
)
|
||||
cls.email_register_rate_limiter.increment_rate_limit(account_email)
|
||||
return token
|
||||
|
||||
@classmethod
|
||||
def send_change_email_email(
|
||||
cls,
|
||||
@ -867,19 +829,6 @@ class AccountService:
|
||||
)
|
||||
return code, token
|
||||
|
||||
@classmethod
|
||||
def generate_email_register_token(
|
||||
cls,
|
||||
email: str,
|
||||
code: str | None = None,
|
||||
additional_data: dict[str, Any] = {},
|
||||
):
|
||||
if not code:
|
||||
code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)])
|
||||
additional_data["code"] = code
|
||||
token = TokenManager.generate_token(email=email, token_type="email_register", additional_data=additional_data)
|
||||
return code, token
|
||||
|
||||
@classmethod
|
||||
def generate_change_email_token(
|
||||
cls,
|
||||
@ -917,10 +866,6 @@ class AccountService:
|
||||
def revoke_reset_password_token(cls, token: str):
|
||||
TokenManager.revoke_token(token, "reset_password")
|
||||
|
||||
@classmethod
|
||||
def revoke_email_register_token(cls, token: str):
|
||||
TokenManager.revoke_token(token, "email_register")
|
||||
|
||||
@classmethod
|
||||
def revoke_change_email_token(cls, token: str):
|
||||
TokenManager.revoke_token(token, "change_email")
|
||||
@ -933,10 +878,6 @@ class AccountService:
|
||||
def get_reset_password_data(cls, token: str) -> dict[str, Any] | None:
|
||||
return TokenManager.get_token_data(token, "reset_password")
|
||||
|
||||
@classmethod
|
||||
def get_email_register_data(cls, token: str) -> dict[str, Any] | None:
|
||||
return TokenManager.get_token_data(token, "email_register")
|
||||
|
||||
@classmethod
|
||||
def get_change_email_data(cls, token: str) -> ChangeEmailTokenData | None:
|
||||
token_data = TokenManager.get_token_data(token, "change_email")
|
||||
@ -1067,16 +1008,6 @@ class AccountService:
|
||||
count = int(count) + 1
|
||||
redis_client.setex(key, dify_config.FORGOT_PASSWORD_LOCKOUT_DURATION, count)
|
||||
|
||||
@staticmethod
|
||||
@redis_fallback(default_return=None)
|
||||
def add_email_register_error_rate_limit(email: str) -> None:
|
||||
key = f"email_register_error_rate_limit:{email}"
|
||||
count = redis_client.get(key)
|
||||
if count is None:
|
||||
count = 0
|
||||
count = int(count) + 1
|
||||
redis_client.setex(key, dify_config.EMAIL_REGISTER_LOCKOUT_DURATION, count)
|
||||
|
||||
@staticmethod
|
||||
@redis_fallback(default_return=False)
|
||||
def is_forgot_password_error_rate_limit(email: str) -> bool:
|
||||
@ -1096,24 +1027,6 @@ class AccountService:
|
||||
key = f"forgot_password_error_rate_limit:{email}"
|
||||
redis_client.delete(key)
|
||||
|
||||
@staticmethod
|
||||
@redis_fallback(default_return=False)
|
||||
def is_email_register_error_rate_limit(email: str) -> bool:
|
||||
key = f"email_register_error_rate_limit:{email}"
|
||||
count = redis_client.get(key)
|
||||
if count is None:
|
||||
return False
|
||||
count = int(count)
|
||||
if count > AccountService.EMAIL_REGISTER_MAX_ERROR_LIMITS:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
@redis_fallback(default_return=None)
|
||||
def reset_email_register_error_rate_limit(email: str):
|
||||
key = f"email_register_error_rate_limit:{email}"
|
||||
redis_client.delete(key)
|
||||
|
||||
@staticmethod
|
||||
@redis_fallback(default_return=None)
|
||||
def add_change_email_error_rate_limit(email: str):
|
||||
@ -1859,28 +1772,39 @@ class TenantService:
|
||||
raise RoleAlreadyAssignedError("The provided role is already assigned to the member.")
|
||||
|
||||
if new_role == "owner":
|
||||
# Find the current owner and change their role to 'admin'
|
||||
if dify_config.RBAC_ENABLED:
|
||||
old_owner_id = AccountService.get_rbac_workspace_owner_account_id(
|
||||
str(tenant.id), operator.id, session=session
|
||||
)
|
||||
owner_role_id = AccountService._resolve_legacy_role_id(
|
||||
tenant_id=str(tenant.id),
|
||||
account_id=operator.id,
|
||||
role=TenantAccountRole.OWNER,
|
||||
)
|
||||
no_access_role_id = AccountService._resolve_role_id_by_tag(
|
||||
tenant_id=str(tenant.id),
|
||||
account_id=operator.id,
|
||||
tag="no_access",
|
||||
)
|
||||
current_roles = RBACService.MemberRoles.get(
|
||||
str(tenant.id), operator.id, old_owner_id, session=session
|
||||
).roles
|
||||
remaining_role_ids = [str(r.id) for r in current_roles if str(r.id) != owner_role_id]
|
||||
RBACService.MemberRoles.replace(
|
||||
tenant_id=str(tenant.id),
|
||||
account_id=operator.id,
|
||||
member_account_id=old_owner_id,
|
||||
role_ids=remaining_role_ids or [no_access_role_id],
|
||||
session=session,
|
||||
)
|
||||
|
||||
current_owner_join = session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.role == "owner")
|
||||
.limit(1)
|
||||
)
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
if current_owner_join:
|
||||
current_owner_join.role = TenantAccountRole.ADMIN
|
||||
elif current_owner_join:
|
||||
admin_role_id = AccountService._resolve_legacy_role_id(
|
||||
tenant_id=str(tenant.id),
|
||||
account_id=operator.id,
|
||||
role=TenantAccountRole.ADMIN,
|
||||
)
|
||||
RBACService.MemberRoles.replace(
|
||||
tenant_id=str(tenant.id),
|
||||
account_id=operator.id,
|
||||
member_account_id=str(current_owner_join.account_id),
|
||||
role_ids=[admin_role_id],
|
||||
session=session,
|
||||
)
|
||||
if current_owner_join:
|
||||
current_owner_join.role = TenantAccountRole.NORMAL
|
||||
|
||||
# Update the role of the target member
|
||||
if dify_config.RBAC_ENABLED:
|
||||
@ -1896,6 +1820,8 @@ class TenantService:
|
||||
role_ids=[resolved_role_id],
|
||||
session=session,
|
||||
)
|
||||
if new_tenant_role == TenantAccountRole.OWNER:
|
||||
target_member_join.role = new_tenant_role
|
||||
else:
|
||||
target_member_join.role = new_tenant_role
|
||||
session.commit()
|
||||
|
||||
@ -21,11 +21,17 @@ from core.app.features.rate_limiting import RateLimit
|
||||
from core.app.features.rate_limiting.rate_limit import rate_limit_context
|
||||
from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig
|
||||
from core.db import session_factory
|
||||
from core.trigger.constants import is_trigger_node_type
|
||||
from enums import DeploymentEdition, QuotaType
|
||||
from extensions.otel import AppGenerateHandler, trace_span
|
||||
from models.model import Account, App, AppMode, EndUser
|
||||
from models.workflow import Workflow, WorkflowRun
|
||||
from services.errors.app import QuotaExceededError, WorkflowIdFormatError, WorkflowNotFoundError
|
||||
from services.errors.app import (
|
||||
QuotaExceededError,
|
||||
TriggerWorkflowServiceModeUnavailableError,
|
||||
WorkflowIdFormatError,
|
||||
WorkflowNotFoundError,
|
||||
)
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.quota_service import QuotaService, unlimited
|
||||
from services.workflow_service import WorkflowService
|
||||
@ -34,6 +40,13 @@ from tasks.app_generate.workflow_execute_task import AppExecutionParams, workflo
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SSE_TASK_START_FALLBACK_MS = 200
|
||||
_MANUAL_WORKFLOW_INVOKE_SOURCES = frozenset(
|
||||
{
|
||||
InvokeFrom.OPENAPI,
|
||||
InvokeFrom.SERVICE_API,
|
||||
InvokeFrom.WEB_APP,
|
||||
}
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from controllers.console.app.workflow import LoopNodeRunPayload
|
||||
@ -290,6 +303,7 @@ class AppGenerateService:
|
||||
case AppMode.WORKFLOW:
|
||||
workflow_id = args.get("workflow_id")
|
||||
workflow = cls._get_workflow(app_model, invoke_from, workflow_id, session=session)
|
||||
cls._ensure_workflow_service_mode_available(workflow=workflow, invoke_from=invoke_from)
|
||||
if streaming:
|
||||
with rate_limit_context(rate_limit, request_id):
|
||||
payload = AppExecutionParams.new(
|
||||
@ -343,6 +357,16 @@ class AppGenerateService:
|
||||
case _:
|
||||
raise ValueError(f"Invalid app mode {app_model.mode}")
|
||||
|
||||
@staticmethod
|
||||
def _ensure_workflow_service_mode_available(*, workflow: Workflow, invoke_from: InvokeFrom) -> None:
|
||||
if invoke_from not in _MANUAL_WORKFLOW_INVOKE_SOURCES:
|
||||
return
|
||||
|
||||
for _, node_data in workflow.walk_nodes():
|
||||
node_type = node_data.get("type")
|
||||
if isinstance(node_type, str) and is_trigger_node_type(node_type):
|
||||
raise TriggerWorkflowServiceModeUnavailableError()
|
||||
|
||||
@staticmethod
|
||||
def _get_max_active_requests(app: App) -> int:
|
||||
"""
|
||||
|
||||
@ -464,6 +464,7 @@ _LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
|
||||
"plugin.install",
|
||||
"credential.use",
|
||||
"app_library.access",
|
||||
"agent.manage",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [
|
||||
@ -471,10 +472,12 @@ _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [
|
||||
"plugin.install",
|
||||
"dataset.create_and_management",
|
||||
"dataset.external.connect",
|
||||
"agent.manage",
|
||||
]
|
||||
|
||||
_LEGACY_APP_OWNER_KEYS: list[str] = [
|
||||
"app.acl.preview",
|
||||
"app.acl.access_point_manage",
|
||||
"app.acl.view_layout",
|
||||
"app.acl.test_and_run",
|
||||
"app.acl.edit",
|
||||
@ -490,6 +493,7 @@ _LEGACY_APP_OWNER_KEYS: list[str] = [
|
||||
_LEGACY_APP_ADMIN_KEYS: list[str] = [
|
||||
"app.acl.preview",
|
||||
"app.acl.view_layout",
|
||||
"app.acl.access_point_manage",
|
||||
"app.acl.test_and_run",
|
||||
"app.acl.edit",
|
||||
"app.acl.import_export_dsl",
|
||||
@ -504,6 +508,7 @@ _LEGACY_APP_ADMIN_KEYS: list[str] = [
|
||||
|
||||
_LEGACY_APP_EDITOR_KEYS: list[str] = [
|
||||
"app.acl.preview",
|
||||
"app.acl.access_point_manage",
|
||||
"app.acl.view_layout",
|
||||
"app.acl.test_and_run",
|
||||
"app.acl.edit",
|
||||
@ -1846,7 +1851,7 @@ class RBACService:
|
||||
)
|
||||
)
|
||||
if current_owner_join and current_owner_join.account_id != member_account_id:
|
||||
current_owner_join.role = TenantAccountRole.ADMIN
|
||||
current_owner_join.role = TenantAccountRole.NORMAL
|
||||
|
||||
target_member_join.role = tenant_role
|
||||
session.commit()
|
||||
|
||||
@ -116,6 +116,30 @@ class AccountEmailResetResult:
|
||||
account: AccountSnapshot | None = None
|
||||
|
||||
|
||||
class AccountEmailRegistrationPhase(StrEnum):
|
||||
REGISTER = "register"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccountEmailRegistrationToken:
|
||||
email: str
|
||||
code: str
|
||||
phase: AccountEmailRegistrationPhase | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccountEmailRegistrationVerification:
|
||||
email: str
|
||||
token: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccountSessionTokens:
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
csrf_token: str
|
||||
|
||||
|
||||
class AccountChangeEmailPhase(StrEnum):
|
||||
OLD_EMAIL = "old_email"
|
||||
OLD_EMAIL_VERIFIED = "old_email_verified"
|
||||
|
||||
38
api/services/entities/notification_entities.py
Normal file
38
api/services/entities/notification_entities.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""Framework-independent notification contracts."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class NotificationContent(NamedTuple):
|
||||
lang: str
|
||||
title: str
|
||||
subtitle: str
|
||||
body: str
|
||||
title_pic_url: str
|
||||
|
||||
|
||||
class AccountNotification(NamedTuple):
|
||||
notification_id: str | None
|
||||
frequency: str | None
|
||||
contents: Mapping[str, NotificationContent]
|
||||
|
||||
|
||||
class AccountNotificationBatch(NamedTuple):
|
||||
should_show: bool
|
||||
notifications: tuple[AccountNotification, ...]
|
||||
|
||||
|
||||
class NotificationItem(NamedTuple):
|
||||
notification_id: str | None
|
||||
frequency: str | None
|
||||
lang: str
|
||||
title: str
|
||||
subtitle: str
|
||||
body: str
|
||||
title_pic_url: str
|
||||
|
||||
|
||||
class NotificationResult(NamedTuple):
|
||||
should_show: bool
|
||||
notifications: tuple[NotificationItem, ...]
|
||||
42
api/services/entities/onboarding_entities.py
Normal file
42
api/services/entities/onboarding_entities.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Framework-independent Step-by-step Tour contracts."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Literal, TypeAlias
|
||||
|
||||
# Assignment-form aliases preserve Literal enum values in Pydantic-generated OpenAPI schemas.
|
||||
StepByStepTourAction: TypeAlias = Literal[ # noqa: UP040
|
||||
"skip",
|
||||
"complete_task",
|
||||
"uncomplete_task",
|
||||
"enable_current_workspace",
|
||||
"disable_current_workspace",
|
||||
]
|
||||
StepByStepTourTaskId: TypeAlias = Literal["home", "studio", "knowledge", "integration"] # noqa: UP040
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StepByStepTourPatch:
|
||||
action: StepByStepTourAction
|
||||
task_id: StepByStepTourTaskId | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StepByStepTourState:
|
||||
account_id: str
|
||||
first_workspace_id: str | None = None
|
||||
skipped: bool = False
|
||||
completed_task_ids: tuple[str, ...] = ()
|
||||
manually_enabled_workspace_ids: tuple[str, ...] = ()
|
||||
manually_disabled_workspace_ids: tuple[str, ...] = ()
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StepByStepTourResult:
|
||||
first_workspace_id: str | None = None
|
||||
skipped: bool = False
|
||||
completed_task_ids: tuple[str, ...] = ()
|
||||
manually_enabled_workspace_ids: tuple[str, ...] = ()
|
||||
manually_disabled_workspace_ids: tuple[str, ...] = ()
|
||||
updated_at: datetime | None = None
|
||||
@ -18,6 +18,21 @@ class WorkflowIdFormatError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE = "trigger_workflow_service_mode_unavailable"
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE = (
|
||||
"This workflow uses a trigger entry and cannot be invoked through Web App, Service API, OpenAPI, or MCP."
|
||||
)
|
||||
|
||||
|
||||
class TriggerWorkflowServiceModeUnavailableError(Exception):
|
||||
"""Raised when a trigger-entry Workflow is invoked through a manual service surface."""
|
||||
|
||||
error_code = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE)
|
||||
|
||||
|
||||
class QuotaExceededError(ValueError):
|
||||
"""Raised when billing quota is exceeded for a feature."""
|
||||
|
||||
|
||||
48
api/services/notification_gateway.py
Normal file
48
api/services/notification_gateway.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""Billing-backed notification gateway."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, override
|
||||
|
||||
from services.billing_service import BillingService
|
||||
from services.entities.notification_entities import (
|
||||
AccountNotification,
|
||||
AccountNotificationBatch,
|
||||
NotificationContent,
|
||||
)
|
||||
from services.notification_service import NotificationGateway
|
||||
|
||||
|
||||
class BillingNotificationGateway(NotificationGateway):
|
||||
@override
|
||||
def get_active(self, account_id: str) -> AccountNotificationBatch:
|
||||
payload = BillingService.get_account_notification(account_id)
|
||||
notifications = tuple(self._map_notification(item) for item in payload.get("notifications") or ())
|
||||
return AccountNotificationBatch(
|
||||
should_show=bool(payload.get("shouldShow")),
|
||||
notifications=notifications,
|
||||
)
|
||||
|
||||
@override
|
||||
def dismiss(self, notification_id: str, account_id: str) -> None:
|
||||
BillingService.dismiss_notification(notification_id=notification_id, account_id=account_id)
|
||||
|
||||
@classmethod
|
||||
def _map_notification(cls, payload: Mapping[str, Any]) -> AccountNotification:
|
||||
raw_contents = payload.get("contents") or {}
|
||||
contents = {language: cls._map_content(content) for language, content in raw_contents.items() if content}
|
||||
return AccountNotification(
|
||||
notification_id=payload.get("notificationId"),
|
||||
frequency=payload.get("frequency"),
|
||||
contents=contents,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _map_content(payload: Mapping[str, Any]) -> NotificationContent:
|
||||
return NotificationContent(
|
||||
# The application service owns the requested-language fallback.
|
||||
lang=payload.get("lang") or "",
|
||||
title=payload.get("title") or "",
|
||||
subtitle=payload.get("subtitle") or "",
|
||||
body=payload.get("body") or "",
|
||||
title_pic_url=payload.get("titlePicUrl") or "",
|
||||
)
|
||||
60
api/services/notification_service.py
Normal file
60
api/services/notification_service.py
Normal file
@ -0,0 +1,60 @@
|
||||
"""Application service for Console account notifications."""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from machinery.context import RequestContext
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.notification_entities import (
|
||||
AccountNotification,
|
||||
AccountNotificationBatch,
|
||||
NotificationContent,
|
||||
NotificationItem,
|
||||
NotificationResult,
|
||||
)
|
||||
|
||||
_FALLBACK_LANGUAGE = "en-US"
|
||||
|
||||
|
||||
class NotificationGateway(Protocol):
|
||||
def get_active(self, account_id: str) -> AccountNotificationBatch: ...
|
||||
|
||||
def dismiss(self, notification_id: str, account_id: str) -> None: ...
|
||||
|
||||
|
||||
class NotificationService:
|
||||
def __init__(self, *, accounts: AccountRepository, notifications: NotificationGateway) -> None:
|
||||
self._accounts = accounts
|
||||
self._notifications = notifications
|
||||
|
||||
def get_active(self, context: RequestContext) -> NotificationResult:
|
||||
batch = self._notifications.get_active(context.account_id)
|
||||
if not batch.should_show:
|
||||
return NotificationResult(should_show=False, notifications=())
|
||||
|
||||
account = self._accounts.get(context.account_id)
|
||||
if account is None:
|
||||
raise RuntimeError("Console account admission resolved an unknown account")
|
||||
language = account.interface_language or _FALLBACK_LANGUAGE
|
||||
|
||||
notifications = tuple(self._localize(notification, language) for notification in batch.notifications)
|
||||
return NotificationResult(should_show=bool(notifications), notifications=notifications)
|
||||
|
||||
def dismiss(self, context: RequestContext, notification_id: str) -> None:
|
||||
self._notifications.dismiss(notification_id, context.account_id)
|
||||
|
||||
@staticmethod
|
||||
def _localize(notification: AccountNotification, language: str) -> NotificationItem:
|
||||
content = (
|
||||
notification.contents.get(language)
|
||||
or notification.contents.get(_FALLBACK_LANGUAGE)
|
||||
or next(iter(notification.contents.values()), NotificationContent(language, "", "", "", ""))
|
||||
)
|
||||
return NotificationItem(
|
||||
notification_id=notification.notification_id,
|
||||
frequency=notification.frequency,
|
||||
lang=content.lang or language,
|
||||
title=content.title,
|
||||
subtitle=content.subtitle,
|
||||
body=content.body,
|
||||
title_pic_url=content.title_pic_url,
|
||||
)
|
||||
@ -1,221 +1,161 @@
|
||||
"""Account-level Step-by-step Tour persistence."""
|
||||
"""Application service for account-level Step-by-step Tour use cases."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from typing import NotRequired, TypedDict
|
||||
from typing import Protocol, get_args
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, scoped_session
|
||||
|
||||
from configs import dify_config
|
||||
from libs.datetime_utils import ensure_naive_utc
|
||||
from models.account import Account
|
||||
from models.onboarding import AccountStepByStepTourState
|
||||
from machinery.context import RequestContext
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.onboarding_entities import (
|
||||
StepByStepTourPatch,
|
||||
StepByStepTourResult,
|
||||
StepByStepTourState,
|
||||
StepByStepTourTaskId,
|
||||
)
|
||||
|
||||
STEP_BY_STEP_TOUR_TASK_IDS = frozenset(("home", "studio", "knowledge", "integration"))
|
||||
_TASK_IDS: frozenset[str] = frozenset(get_args(StepByStepTourTaskId))
|
||||
|
||||
|
||||
class StepByStepTourStateResponse(TypedDict):
|
||||
first_workspace_id: str | None
|
||||
skipped: bool
|
||||
completed_task_ids: list[str]
|
||||
manually_enabled_workspace_ids: list[str]
|
||||
manually_disabled_workspace_ids: list[str]
|
||||
updated_at: datetime | None
|
||||
class StepByStepTourStateRepository(Protocol):
|
||||
def get(self, account_id: str) -> StepByStepTourState | None: ...
|
||||
|
||||
def initialize(self, account_id: str, first_workspace_id: str) -> StepByStepTourState: ...
|
||||
|
||||
class StepByStepTourPatch(TypedDict):
|
||||
action: str
|
||||
task_id: NotRequired[str | None]
|
||||
def mutate(
|
||||
self,
|
||||
account_id: str,
|
||||
mutation: Callable[[StepByStepTourState], StepByStepTourState],
|
||||
) -> StepByStepTourState: ...
|
||||
|
||||
|
||||
class StepByStepTourService:
|
||||
"""Coordinate persisted tour state with account eligibility rules."""
|
||||
|
||||
@classmethod
|
||||
def get_state(
|
||||
cls,
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
account: Account,
|
||||
current_tenant_id: str,
|
||||
session: Session | scoped_session,
|
||||
) -> StepByStepTourStateResponse:
|
||||
eligible = cls.is_eligible(account)
|
||||
state = cls._get_state(account.id, session=session)
|
||||
accounts: AccountRepository,
|
||||
states: StepByStepTourStateRepository,
|
||||
enabled: bool,
|
||||
rollout_started_at: datetime | None,
|
||||
) -> None:
|
||||
self._accounts = accounts
|
||||
self._states = states
|
||||
self._enabled = enabled
|
||||
self._rollout_started_at = rollout_started_at
|
||||
|
||||
if eligible:
|
||||
state = cls._ensure_state(account.id, session=session, state=state)
|
||||
if state.first_workspace_id is None:
|
||||
state.first_workspace_id = current_tenant_id
|
||||
session.commit()
|
||||
session.refresh(state)
|
||||
def get_state(self, context: RequestContext) -> StepByStepTourResult:
|
||||
workspace_id = self._require_workspace(context)
|
||||
account = self._accounts.get(context.account_id)
|
||||
if account is None:
|
||||
raise RuntimeError("Console account admission resolved an unknown account")
|
||||
|
||||
return cls._build_response(state=state)
|
||||
if not self._is_eligible(account.initialized_at or account.created_at):
|
||||
return self._to_result(self._states.get(context.account_id))
|
||||
|
||||
@classmethod
|
||||
def patch_state(
|
||||
cls,
|
||||
*,
|
||||
account: Account,
|
||||
current_tenant_id: str,
|
||||
patch: StepByStepTourPatch,
|
||||
session: Session | scoped_session,
|
||||
) -> StepByStepTourStateResponse:
|
||||
state = cls._ensure_state(account.id, session=session, state=None)
|
||||
cls._apply_action(
|
||||
state=state,
|
||||
action=patch["action"],
|
||||
task_id=patch.get("task_id"),
|
||||
current_tenant_id=current_tenant_id,
|
||||
return self._to_result(self._states.initialize(context.account_id, workspace_id))
|
||||
|
||||
def patch_state(self, context: RequestContext, patch: StepByStepTourPatch) -> StepByStepTourResult:
|
||||
workspace_id = self._require_workspace(context)
|
||||
state = self._states.mutate(
|
||||
context.account_id,
|
||||
lambda current: self._apply_action(current, patch=patch, workspace_id=workspace_id),
|
||||
)
|
||||
return self._to_result(state)
|
||||
|
||||
session.commit()
|
||||
session.refresh(state)
|
||||
return cls._build_response(state=state)
|
||||
|
||||
@classmethod
|
||||
def is_eligible(cls, account: Account) -> bool:
|
||||
if not dify_config.ENABLE_STEP_BY_STEP_TOUR:
|
||||
def _is_eligible(self, account_started_at: datetime) -> bool:
|
||||
if not self._enabled or self._rollout_started_at is None:
|
||||
return False
|
||||
|
||||
rollout_started_at = dify_config.STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT
|
||||
if rollout_started_at is None:
|
||||
return False
|
||||
|
||||
account_started_at = account.initialized_at or account.created_at
|
||||
if account_started_at is None:
|
||||
return False
|
||||
|
||||
return ensure_naive_utc(account_started_at) >= ensure_naive_utc(rollout_started_at)
|
||||
|
||||
@classmethod
|
||||
def _get_state(
|
||||
cls,
|
||||
account_id: str,
|
||||
*,
|
||||
session: Session | scoped_session,
|
||||
) -> AccountStepByStepTourState | None:
|
||||
stmt = select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == account_id).limit(1)
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
def _ensure_state(
|
||||
cls,
|
||||
account_id: str,
|
||||
*,
|
||||
session: Session | scoped_session,
|
||||
state: AccountStepByStepTourState | None,
|
||||
) -> AccountStepByStepTourState:
|
||||
if state is None:
|
||||
state = cls._get_state(account_id, session=session)
|
||||
if state is not None:
|
||||
return state
|
||||
|
||||
state = AccountStepByStepTourState(account_id=account_id)
|
||||
session.add(state)
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError:
|
||||
# Another tab/device can create the account row between our read and insert.
|
||||
session.rollback()
|
||||
state = cls._get_state(account_id, session=session)
|
||||
if state is None:
|
||||
raise
|
||||
return state
|
||||
return ensure_naive_utc(account_started_at) >= ensure_naive_utc(self._rollout_started_at)
|
||||
|
||||
@classmethod
|
||||
def _apply_action(
|
||||
cls,
|
||||
state: StepByStepTourState,
|
||||
*,
|
||||
state: AccountStepByStepTourState,
|
||||
action: str,
|
||||
task_id: str | None,
|
||||
current_tenant_id: str,
|
||||
) -> None:
|
||||
match action:
|
||||
patch: StepByStepTourPatch,
|
||||
workspace_id: str,
|
||||
) -> StepByStepTourState:
|
||||
match patch.action:
|
||||
case "skip":
|
||||
state.skipped = True
|
||||
state.manually_enabled_workspace_ids = cls._remove_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
return replace(
|
||||
state,
|
||||
skipped=True,
|
||||
manually_enabled_workspace_ids=cls._remove_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
workspace_id,
|
||||
),
|
||||
)
|
||||
case "complete_task":
|
||||
if task_id is None:
|
||||
raise ValueError("task_id is required")
|
||||
cls._validate_task_id(task_id)
|
||||
state.completed_task_ids = cls._add_id(state.completed_task_ids, task_id)
|
||||
task_id = cls._require_task_id(patch.task_id)
|
||||
return replace(state, completed_task_ids=cls._add_id(state.completed_task_ids, task_id))
|
||||
case "uncomplete_task":
|
||||
if task_id is None:
|
||||
raise ValueError("task_id is required")
|
||||
cls._validate_task_id(task_id)
|
||||
state.completed_task_ids = cls._remove_id(state.completed_task_ids, task_id)
|
||||
task_id = cls._require_task_id(patch.task_id)
|
||||
return replace(state, completed_task_ids=cls._remove_id(state.completed_task_ids, task_id))
|
||||
case "enable_current_workspace":
|
||||
state.skipped = False
|
||||
state.manually_enabled_workspace_ids = cls._add_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
)
|
||||
state.manually_disabled_workspace_ids = cls._remove_id(
|
||||
state.manually_disabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
return replace(
|
||||
state,
|
||||
skipped=False,
|
||||
manually_enabled_workspace_ids=cls._add_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
workspace_id,
|
||||
),
|
||||
manually_disabled_workspace_ids=cls._remove_id(
|
||||
state.manually_disabled_workspace_ids,
|
||||
workspace_id,
|
||||
),
|
||||
)
|
||||
case "disable_current_workspace":
|
||||
state.manually_enabled_workspace_ids = cls._remove_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
)
|
||||
state.manually_disabled_workspace_ids = cls._add_id(
|
||||
state.manually_disabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
return replace(
|
||||
state,
|
||||
manually_enabled_workspace_ids=cls._remove_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
workspace_id,
|
||||
),
|
||||
manually_disabled_workspace_ids=cls._add_id(
|
||||
state.manually_disabled_workspace_ids,
|
||||
workspace_id,
|
||||
),
|
||||
)
|
||||
case _:
|
||||
raise ValueError(f"Unsupported action: {action}")
|
||||
|
||||
@classmethod
|
||||
def _build_response(
|
||||
cls,
|
||||
*,
|
||||
state: AccountStepByStepTourState | None,
|
||||
) -> StepByStepTourStateResponse:
|
||||
if state is None:
|
||||
return {
|
||||
"first_workspace_id": None,
|
||||
"skipped": False,
|
||||
"completed_task_ids": [],
|
||||
"manually_enabled_workspace_ids": [],
|
||||
"manually_disabled_workspace_ids": [],
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"first_workspace_id": state.first_workspace_id,
|
||||
"skipped": state.skipped,
|
||||
"completed_task_ids": cls._normalize_ids(state.completed_task_ids),
|
||||
"manually_enabled_workspace_ids": cls._normalize_ids(state.manually_enabled_workspace_ids),
|
||||
"manually_disabled_workspace_ids": cls._normalize_ids(state.manually_disabled_workspace_ids),
|
||||
"updated_at": state.updated_at,
|
||||
}
|
||||
raise ValueError(f"Unsupported action: {patch.action}")
|
||||
|
||||
@staticmethod
|
||||
def _validate_task_id(task_id: str) -> None:
|
||||
if task_id not in STEP_BY_STEP_TOUR_TASK_IDS:
|
||||
def _require_workspace(context: RequestContext) -> str:
|
||||
if context.active_workspace_id is None:
|
||||
raise RuntimeError("Console account admission did not resolve an active workspace")
|
||||
return context.active_workspace_id
|
||||
|
||||
@staticmethod
|
||||
def _require_task_id(task_id: str | None) -> str:
|
||||
if task_id is None:
|
||||
raise ValueError("task_id is required")
|
||||
if task_id not in _TASK_IDS:
|
||||
raise ValueError(f"Unsupported task_id: {task_id}")
|
||||
return task_id
|
||||
|
||||
@classmethod
|
||||
def _add_id(cls, values: list[str], value: str) -> list[str]:
|
||||
def _add_id(cls, values: tuple[str, ...], value: str) -> tuple[str, ...]:
|
||||
normalized = cls._normalize_ids(values)
|
||||
if value in normalized:
|
||||
return normalized
|
||||
return [*normalized, value]
|
||||
return normalized if value in normalized else (*normalized, value)
|
||||
|
||||
@classmethod
|
||||
def _remove_id(cls, values: list[str], value: str) -> list[str]:
|
||||
return [item for item in cls._normalize_ids(values) if item != value]
|
||||
def _remove_id(cls, values: tuple[str, ...], value: str) -> tuple[str, ...]:
|
||||
return tuple(item for item in cls._normalize_ids(values) if item != value)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ids(values: list[str]) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
for value in values:
|
||||
if value not in normalized:
|
||||
normalized.append(value)
|
||||
return normalized
|
||||
def _normalize_ids(values: tuple[str, ...]) -> tuple[str, ...]:
|
||||
return tuple(dict.fromkeys(values))
|
||||
|
||||
@staticmethod
|
||||
def _to_result(state: StepByStepTourState | None) -> StepByStepTourResult:
|
||||
if state is None:
|
||||
return StepByStepTourResult()
|
||||
return StepByStepTourResult(
|
||||
first_workspace_id=state.first_workspace_id,
|
||||
skipped=state.skipped,
|
||||
completed_task_ids=tuple(dict.fromkeys(state.completed_task_ids)),
|
||||
manually_enabled_workspace_ids=tuple(dict.fromkeys(state.manually_enabled_workspace_ids)),
|
||||
manually_disabled_workspace_ids=tuple(dict.fromkeys(state.manually_disabled_workspace_ids)),
|
||||
updated_at=state.updated_at,
|
||||
)
|
||||
|
||||
@ -300,7 +300,7 @@ class TestOwnerTransferApiWithContainers:
|
||||
)
|
||||
assert (
|
||||
factory.get_join(db_session_with_containers, tenant=tenant, account=current_user).role
|
||||
== TenantAccountRole.ADMIN
|
||||
== TenantAccountRole.NORMAL
|
||||
)
|
||||
mock_new_owner_email.assert_called_once()
|
||||
mock_old_owner_email.assert_called_once()
|
||||
|
||||
@ -1671,7 +1671,7 @@ class TestTenantService:
|
||||
|
||||
def test_update_member_role_to_owner(self, db_session_with_containers: Session, mock_external_service_dependencies):
|
||||
"""
|
||||
Test updating member role to owner (should change current owner to admin).
|
||||
Test updating member role to owner (should change current owner to normal).
|
||||
"""
|
||||
fake = Faker()
|
||||
tenant_name = fake.company()
|
||||
@ -1723,7 +1723,7 @@ class TestTenantService:
|
||||
.filter_by(tenant_id=tenant.id, account_id=member_account.id)
|
||||
.first()
|
||||
)
|
||||
assert owner_join.role == "admin"
|
||||
assert owner_join.role == "normal"
|
||||
assert member_join.role == "owner"
|
||||
|
||||
def test_update_member_role_already_assigned(
|
||||
|
||||
@ -238,6 +238,42 @@ def test_patch_union_schema_markdown_fills_regular_schema_union_property(tmp_pat
|
||||
assert "| value | string<br>integer<br>number<br>boolean | | No |" in patched
|
||||
|
||||
|
||||
def test_patch_union_schema_markdown_preserves_nullable_enum_values(tmp_path: Path):
|
||||
module = _load_generate_swagger_markdown_docs_module()
|
||||
spec_path = tmp_path / "console-openapi.json"
|
||||
spec_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"StepByStepTourStatePatchPayload": {
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"anyOf": [
|
||||
{"enum": ["home", "studio"], "type": "string"},
|
||||
{"type": "null"},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
markdown = """#### StepByStepTourStatePatchPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| task_id | string | Task ID | No |
|
||||
"""
|
||||
|
||||
patched = module._patch_union_schema_markdown(markdown, spec_path)
|
||||
|
||||
assert '| task_id | string, <br>**Available values:** "home", "studio" | Task ID | No |' in patched
|
||||
|
||||
|
||||
def test_patch_union_schema_markdown_fills_array_item_union_property(tmp_path: Path):
|
||||
module = _load_generate_swagger_markdown_docs_module()
|
||||
spec_path = tmp_path / "console-openapi.json"
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from inspect import getsource, unwrap
|
||||
from types import SimpleNamespace
|
||||
@ -55,7 +56,7 @@ from controllers.console.agent.roster import (
|
||||
from controllers.console.app import completion as completion_controller
|
||||
from controllers.console.app import message as message_controller
|
||||
from controllers.console.app.completion import AgentBuildChatFinalizeApi, AgentChatMessageApi, AgentChatMessageStopApi
|
||||
from controllers.console.app.error import CompletionRequestError
|
||||
from controllers.console.app.error import AgentSessionConfigurationChangedError, CompletionRequestError
|
||||
from controllers.console.app.message import (
|
||||
AgentChatMessageListApi,
|
||||
AgentMessageApi,
|
||||
@ -308,9 +309,15 @@ def account_id() -> str:
|
||||
|
||||
|
||||
def test_agent_app_list_and_create_use_agent_route(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str, sqlite_session: Session
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
account_id: str,
|
||||
sqlite_session: Session,
|
||||
config_overrides: Callable[..., None],
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
replace_whitelist = MagicMock()
|
||||
initialize_access = MagicMock()
|
||||
|
||||
class FakeAppService:
|
||||
def get_app(self, app_obj: object, *, session: object) -> object:
|
||||
@ -396,7 +403,9 @@ def test_agent_app_list_and_create_use_agent_route(
|
||||
lambda _self, **kwargs: {"agent-list": "debug-conversation-list"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 0
|
||||
roster_controller.AgentRosterService,
|
||||
"count_agent_app_debug_conversation_messages",
|
||||
lambda _self, **kwargs: 0,
|
||||
)
|
||||
|
||||
def get_or_create_debug_conversation(_self: object, **kwargs: object) -> str:
|
||||
@ -413,6 +422,13 @@ def test_agent_app_list_and_create_use_agent_route(
|
||||
"get_system_features",
|
||||
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
|
||||
)
|
||||
config_overrides(RBAC_ENABLED=True)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.enterprise_rbac_service.RBACService.AppAccess,
|
||||
"replace_whitelist",
|
||||
replace_whitelist,
|
||||
)
|
||||
monkeypatch.setattr(roster_controller.initialize_created_app_rbac_access_task, "delay", initialize_access)
|
||||
with app.test_request_context(
|
||||
"/console/api/agent?page=1&limit=10&mode=workflow&sort_by=recently_created"
|
||||
"&is_created_by_me=true&publication_status=published"
|
||||
@ -453,12 +469,22 @@ def test_agent_app_list_and_create_use_agent_route(
|
||||
assert count_params.agent_is_published is True
|
||||
with app.test_request_context(
|
||||
"/console/api/agent",
|
||||
json={"name": "Iris", "description": "Agent app", "role": "Coordinator", "icon_type": "emoji", "icon": "robot"},
|
||||
json={
|
||||
"name": "Iris",
|
||||
"description": "Agent app",
|
||||
"role": "Coordinator",
|
||||
"icon_type": "emoji",
|
||||
"icon": "robot",
|
||||
},
|
||||
):
|
||||
created, status = unwrap(AgentAppListApi.post)(
|
||||
AgentAppListApi(),
|
||||
AgentAppCreatePayload(
|
||||
name="Iris", description="Agent app", role="Coordinator", icon_type="emoji", icon="robot"
|
||||
name="Iris",
|
||||
description="Agent app",
|
||||
role="Coordinator",
|
||||
icon_type="emoji",
|
||||
icon="robot",
|
||||
),
|
||||
sqlite_session,
|
||||
"tenant-1",
|
||||
@ -481,6 +507,81 @@ def test_agent_app_list_and_create_use_agent_route(
|
||||
"account_id": account_id,
|
||||
"commit": False,
|
||||
}
|
||||
replace_whitelist.assert_called_once()
|
||||
assert replace_whitelist.call_args.args[:3] == ("tenant-1", account_id, "app-created")
|
||||
replace_payload = replace_whitelist.call_args.args[3]
|
||||
assert replace_payload.automatic_include_workspace_members is True
|
||||
initialize_access.assert_called_once_with("tenant-1", account_id, app_id="app-created")
|
||||
|
||||
|
||||
def test_agent_app_create_skips_rbac_access_initialization_when_rbac_is_disabled(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
account_id: str,
|
||||
sqlite_session: Session,
|
||||
config_overrides: Callable[..., None],
|
||||
) -> None:
|
||||
replace_whitelist = MagicMock()
|
||||
initialize_access = MagicMock()
|
||||
|
||||
class FakeAppService:
|
||||
def get_app(self, app_obj: object, *, session: object) -> object:
|
||||
return app_obj
|
||||
|
||||
def create_app(self, tenant_id: str, params, current_user: object, *, session: object) -> object:
|
||||
return _app_detail_obj(id="app-created", bound_agent_id="agent-created")
|
||||
|
||||
monkeypatch.setattr(roster_controller, "AppService", FakeAppService)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService,
|
||||
"get_app_backing_agent",
|
||||
lambda _self, **kwargs: Agent(
|
||||
id="agent-created",
|
||||
app_id="app-created",
|
||||
backing_app_id=None,
|
||||
role="Created role",
|
||||
active_config_snapshot_id=None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService,
|
||||
"get_or_create_build_conversation",
|
||||
lambda _self, **kwargs: "debug-conversation-created",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 0
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.FeatureService,
|
||||
"get_system_features",
|
||||
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
|
||||
)
|
||||
config_overrides(RBAC_ENABLED=False)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.enterprise_rbac_service.RBACService.AppAccess,
|
||||
"replace_whitelist",
|
||||
replace_whitelist,
|
||||
)
|
||||
monkeypatch.setattr(roster_controller.initialize_created_app_rbac_access_task, "delay", initialize_access)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/agent",
|
||||
json={"name": "Iris", "description": "Agent app", "role": "Coordinator", "icon_type": "emoji", "icon": "robot"},
|
||||
):
|
||||
created, status = unwrap(AgentAppListApi.post)(
|
||||
AgentAppListApi(),
|
||||
AgentAppCreatePayload(
|
||||
name="Iris", description="Agent app", role="Coordinator", icon_type="emoji", icon="robot"
|
||||
),
|
||||
sqlite_session,
|
||||
"tenant-1",
|
||||
_account(account_id=account_id),
|
||||
)
|
||||
|
||||
assert status == 201
|
||||
assert created["id"] == "agent-created"
|
||||
replace_whitelist.assert_not_called()
|
||||
initialize_access.assert_not_called()
|
||||
|
||||
|
||||
def test_agent_app_create_payload_allows_optional_role() -> None:
|
||||
@ -1633,6 +1734,38 @@ def test_agent_chat_stream_preflight_raises_first_error_event() -> None:
|
||||
assert stream.closed is True
|
||||
|
||||
|
||||
def test_agent_chat_stream_preflight_preserves_session_configuration_error() -> None:
|
||||
class ClosableStream:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
self._chunks = iter(
|
||||
[
|
||||
"event: ping\n\n",
|
||||
(
|
||||
'data: {"event":"error","message":"Start a new conversation to continue.",'
|
||||
'"code":"agent_session_configuration_changed","status":409}\n\n'
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self) -> str:
|
||||
return next(self._chunks)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
stream = ClosableStream()
|
||||
with pytest.raises(AgentSessionConfigurationChangedError) as exc_info:
|
||||
completion_controller._raise_agent_stream_error_before_response(stream)
|
||||
assert exc_info.value.code == 409
|
||||
assert exc_info.value.error_code == "agent_session_configuration_changed"
|
||||
assert "Start a new conversation" in exc_info.value.description
|
||||
assert stream.closed is True
|
||||
|
||||
|
||||
def test_agent_chat_stream_preflight_preserves_first_normal_event() -> None:
|
||||
stream = iter(
|
||||
["event: ping\n\n", 'data: {"event":"message","answer":"hello"}\n\n', 'data: {"event":"message_end"}\n\n']
|
||||
|
||||
@ -1,30 +1,57 @@
|
||||
"""Unit tests for email register controller endpoints."""
|
||||
"""Unit tests for the email-registration Flask adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from unittest.mock import MagicMock, patch
|
||||
from collections.abc import Callable, Generator
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.console import bp as console_bp
|
||||
from controllers.console.auth.email_register import (
|
||||
EmailRegisterCheckApi,
|
||||
EmailRegisterResetApi,
|
||||
EmailRegisterResetPayload,
|
||||
EmailRegisterSendEmailApi,
|
||||
)
|
||||
from controllers.console.auth.error import NormalizedEmailAlreadyInUseError
|
||||
from controllers.console.error import AccountInFreezeError, EmailDomainSuspendedError
|
||||
from controllers.console.auth.error import (
|
||||
EmailAlreadyInUseError,
|
||||
EmailCodeError,
|
||||
EmailRegisterLimitError,
|
||||
EmailRegisterRateLimitExceededError,
|
||||
InvalidEmailError,
|
||||
InvalidTokenError,
|
||||
NormalizedEmailAlreadyInUseError,
|
||||
PasswordMismatchError,
|
||||
)
|
||||
from controllers.console.error import (
|
||||
AccountInFreezeError,
|
||||
EmailDomainSuspendedError,
|
||||
EmailSendIpLimitError,
|
||||
SeatsLimitExceeded,
|
||||
)
|
||||
from enums import DeploymentEdition
|
||||
from models.account import Account
|
||||
from services.entities.feature_entities import SystemFeatureModel
|
||||
from services.errors.account import (
|
||||
from services.account_email_registration_service import AccountEmailRegistrationService
|
||||
from services.account_errors import (
|
||||
AccountEmailAlreadyInUseError,
|
||||
AccountEmailDomainSuspendedError,
|
||||
AccountEmailFrozenError,
|
||||
AccountNormalizedEmailAlreadyInUseError,
|
||||
AccountRegisterError,
|
||||
)
|
||||
from services.errors.account import (
|
||||
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
|
||||
EmailRegistrationPasswordMismatchError,
|
||||
EmailRegistrationSeatsLimitError,
|
||||
EmailRegistrationSendIPLimitedError,
|
||||
EmailRegistrationSendRateLimitError,
|
||||
EmailRegistrationVerificationLimitError,
|
||||
InvalidEmailRegistrationAddressError,
|
||||
InvalidEmailRegistrationCodeError,
|
||||
InvalidEmailRegistrationTokenError,
|
||||
)
|
||||
from services.entities.account_entities import AccountEmailRegistrationVerification, AccountSessionTokens
|
||||
from services.entities.feature_entities import SystemFeatureModel
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@ -32,6 +59,33 @@ def _cloud_edition(config_overrides: Callable[..., None]) -> None:
|
||||
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _request(
|
||||
app: Flask,
|
||||
service: Mock,
|
||||
*,
|
||||
path: str,
|
||||
payload: dict[str, str],
|
||||
) -> Generator[None, None, None]:
|
||||
services = SimpleNamespace(accounts=SimpleNamespace(email_registration=service))
|
||||
features = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.CLOUD,
|
||||
enable_email_password_login=True,
|
||||
is_allow_register=True,
|
||||
)
|
||||
with (
|
||||
patch("controllers.console.auth.email_register.application_services", return_value=services),
|
||||
patch("controllers.console.flask_admission.FeatureService.get_system_features", return_value=features),
|
||||
patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1"),
|
||||
app.test_request_context(path, method="POST", json=payload),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def _service() -> Mock:
|
||||
return Mock(spec=AccountEmailRegistrationService)
|
||||
|
||||
|
||||
def test_normalized_email_conflict_exposes_a_distinct_error_code() -> None:
|
||||
error = NormalizedEmailAlreadyInUseError()
|
||||
|
||||
@ -40,321 +94,210 @@ def test_normalized_email_conflict_exposes_a_distinct_error_code() -> None:
|
||||
assert error.data["code"] == "normalized_email_already_in_use"
|
||||
|
||||
|
||||
class TestEmailRegisterSendEmailApi:
|
||||
@patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback")
|
||||
@patch("controllers.console.auth.email_register.AccountService.send_email_register_email")
|
||||
@patch("controllers.console.auth.email_register.BillingService.get_email_freeze_type")
|
||||
@patch("controllers.console.auth.email_register.AccountService.is_email_send_ip_limit", return_value=False)
|
||||
@patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1")
|
||||
def test_send_email_normalizes_and_falls_back(
|
||||
self,
|
||||
mock_extract_ip,
|
||||
mock_is_email_send_ip_limit,
|
||||
mock_is_freeze,
|
||||
mock_send_mail,
|
||||
mock_get_account,
|
||||
app: Flask,
|
||||
def test_send_email_delegates_with_remote_ip(app: Flask) -> None:
|
||||
service = _service()
|
||||
service.send_code.return_value = "token-123"
|
||||
|
||||
with _request(
|
||||
app,
|
||||
service,
|
||||
path="/email-register/send-email",
|
||||
payload={"email": "Invitee@Example.com", "language": "zh-Hans"},
|
||||
):
|
||||
mock_send_mail.return_value = "token-123"
|
||||
mock_is_freeze.return_value = False
|
||||
account = Account(name="Invitee", email="invitee@example.com")
|
||||
mock_get_account.return_value = account
|
||||
response = EmailRegisterSendEmailApi().post()
|
||||
|
||||
feature_flags = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_email_password_login=True,
|
||||
is_allow_register=True,
|
||||
)
|
||||
with (
|
||||
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
|
||||
):
|
||||
with app.test_request_context(
|
||||
"/email-register/send-email",
|
||||
method="POST",
|
||||
json={"email": "Invitee@Example.com", "language": "en-US"},
|
||||
):
|
||||
response = EmailRegisterSendEmailApi().post()
|
||||
assert response == {"result": "success", "data": "token-123"}
|
||||
assert service.send_code.call_args.kwargs == {
|
||||
"remote_ip": "127.0.0.1",
|
||||
"requested_email": "Invitee@Example.com",
|
||||
"requested_language": "zh-Hans",
|
||||
}
|
||||
|
||||
assert response == {"result": "success", "data": "token-123"}
|
||||
mock_is_freeze.assert_called_once_with("invitee@example.com")
|
||||
mock_send_mail.assert_called_once_with(email="invitee@example.com", account=account, language="en-US")
|
||||
mock_extract_ip.assert_called_once()
|
||||
mock_is_email_send_ip_limit.assert_called_once_with("127.0.0.1")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("freeze_type", "expected_error"),
|
||||
[
|
||||
("freeze", AccountInFreezeError),
|
||||
("email_domain_suspended", EmailDomainSuspendedError),
|
||||
],
|
||||
@pytest.mark.parametrize(
|
||||
("service_error", "http_error"),
|
||||
[
|
||||
pytest.param(EmailRegistrationSendIPLimitedError(), EmailSendIpLimitError, id="ip-limit"),
|
||||
pytest.param(EmailRegistrationSendRateLimitError(1), EmailRegisterRateLimitExceededError, id="send-limit"),
|
||||
pytest.param(AccountEmailFrozenError(), AccountInFreezeError, id="frozen"),
|
||||
pytest.param(AccountEmailDomainSuspendedError(), EmailDomainSuspendedError, id="suspended-domain"),
|
||||
],
|
||||
)
|
||||
def test_send_email_translates_application_errors(
|
||||
app: Flask,
|
||||
service_error: Exception,
|
||||
http_error: type[Exception],
|
||||
) -> None:
|
||||
service = _service()
|
||||
service.send_code.side_effect = service_error
|
||||
|
||||
with _request(
|
||||
app,
|
||||
service,
|
||||
path="/email-register/send-email",
|
||||
payload={"email": "invitee@example.com"},
|
||||
):
|
||||
with pytest.raises(http_error):
|
||||
EmailRegisterSendEmailApi().post()
|
||||
|
||||
|
||||
def test_verify_email_code_serializes_application_result(app: Flask) -> None:
|
||||
service = _service()
|
||||
service.verify_code.return_value = AccountEmailRegistrationVerification(
|
||||
email="user@example.com",
|
||||
token="verified-token",
|
||||
)
|
||||
@patch("controllers.console.auth.email_register.BillingService.get_email_freeze_type")
|
||||
@patch("controllers.console.auth.email_register.AccountService.is_email_send_ip_limit", return_value=False)
|
||||
@patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1")
|
||||
def test_send_email_rejects_frozen_email(
|
||||
self,
|
||||
mock_extract_ip,
|
||||
mock_is_email_send_ip_limit,
|
||||
mock_get_freeze_type,
|
||||
app: Flask,
|
||||
freeze_type,
|
||||
expected_error,
|
||||
|
||||
with _request(
|
||||
app,
|
||||
service,
|
||||
path="/email-register/validity",
|
||||
payload={"email": "User@Example.com", "code": "123456", "token": "pending-token"},
|
||||
):
|
||||
mock_get_freeze_type.return_value = freeze_type
|
||||
feature_flags = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_email_password_login=True,
|
||||
is_allow_register=True,
|
||||
)
|
||||
response = EmailRegisterCheckApi().post()
|
||||
|
||||
with (
|
||||
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
|
||||
):
|
||||
with app.test_request_context(
|
||||
"/email-register/send-email",
|
||||
method="POST",
|
||||
json={"email": "Invitee@Example.com"},
|
||||
):
|
||||
with pytest.raises(expected_error):
|
||||
EmailRegisterSendEmailApi().post()
|
||||
|
||||
mock_get_freeze_type.assert_called_once_with("invitee@example.com")
|
||||
mock_is_email_send_ip_limit.assert_called_once_with("127.0.0.1")
|
||||
mock_extract_ip.assert_called_once()
|
||||
|
||||
|
||||
class TestEmailRegisterCheckApi:
|
||||
@patch("controllers.console.auth.email_register.AccountService.reset_email_register_error_rate_limit")
|
||||
@patch("controllers.console.auth.email_register.AccountService.generate_email_register_token")
|
||||
@patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token")
|
||||
@patch("controllers.console.auth.email_register.AccountService.add_email_register_error_rate_limit")
|
||||
@patch("controllers.console.auth.email_register.AccountService.get_email_register_data")
|
||||
@patch("controllers.console.auth.email_register.AccountService.is_email_register_error_rate_limit")
|
||||
def test_validity_normalizes_email_before_checks(
|
||||
self,
|
||||
mock_rate_limit_check,
|
||||
mock_get_data,
|
||||
mock_add_rate,
|
||||
mock_revoke,
|
||||
mock_generate_token,
|
||||
mock_reset_rate,
|
||||
app: Flask,
|
||||
):
|
||||
mock_rate_limit_check.return_value = False
|
||||
mock_get_data.return_value = {"email": "User@Example.com", "code": "4321"}
|
||||
mock_generate_token.return_value = (None, "new-token")
|
||||
|
||||
feature_flags = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_email_password_login=True,
|
||||
is_allow_register=True,
|
||||
)
|
||||
with (
|
||||
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
|
||||
):
|
||||
with app.test_request_context(
|
||||
"/email-register/validity",
|
||||
method="POST",
|
||||
json={"email": "User@Example.com", "code": "4321", "token": "token-123"},
|
||||
):
|
||||
response = EmailRegisterCheckApi().post()
|
||||
|
||||
assert response == {"is_valid": True, "email": "user@example.com", "token": "new-token"}
|
||||
mock_rate_limit_check.assert_called_once_with("user@example.com")
|
||||
mock_generate_token.assert_called_once_with(
|
||||
"user@example.com", code="4321", additional_data={"phase": "register"}
|
||||
)
|
||||
mock_reset_rate.assert_called_once_with("user@example.com")
|
||||
mock_add_rate.assert_not_called()
|
||||
mock_revoke.assert_called_once_with("token-123")
|
||||
|
||||
|
||||
class TestEmailRegisterResetApi:
|
||||
@pytest.mark.parametrize(
|
||||
("service_error", "expected_error"),
|
||||
[
|
||||
(EmailDomainSuspendedRegistrationError(), EmailDomainSuspendedError),
|
||||
(AccountNormalizedEmailAlreadyInUseError(), NormalizedEmailAlreadyInUseError),
|
||||
(AccountRegisterError("frozen"), AccountInFreezeError),
|
||||
],
|
||||
assert response == {"is_valid": True, "email": "user@example.com", "token": "verified-token"}
|
||||
service.verify_code.assert_called_once_with(
|
||||
email="User@Example.com",
|
||||
code="123456",
|
||||
token="pending-token",
|
||||
)
|
||||
@patch("controllers.console.auth.email_register.AccountService.create_account_and_tenant")
|
||||
def test_create_new_account_translates_freeze_errors(
|
||||
self,
|
||||
mock_create_account,
|
||||
service_error,
|
||||
expected_error,
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service_error", "http_error"),
|
||||
[
|
||||
pytest.param(EmailRegistrationVerificationLimitError(), EmailRegisterLimitError, id="attempt-limit"),
|
||||
pytest.param(InvalidEmailRegistrationTokenError(), InvalidTokenError, id="token"),
|
||||
pytest.param(InvalidEmailRegistrationAddressError(), InvalidEmailError, id="email"),
|
||||
pytest.param(InvalidEmailRegistrationCodeError(), EmailCodeError, id="code"),
|
||||
],
|
||||
)
|
||||
def test_verify_email_code_translates_application_errors(
|
||||
app: Flask,
|
||||
service_error: Exception,
|
||||
http_error: type[Exception],
|
||||
) -> None:
|
||||
service = _service()
|
||||
service.verify_code.side_effect = service_error
|
||||
|
||||
with _request(
|
||||
app,
|
||||
service,
|
||||
path="/email-register/validity",
|
||||
payload={"email": "user@example.com", "code": "wrong", "token": "pending-token"},
|
||||
):
|
||||
mock_create_account.side_effect = service_error
|
||||
with pytest.raises(http_error):
|
||||
EmailRegisterCheckApi().post()
|
||||
|
||||
with pytest.raises(expected_error):
|
||||
EmailRegisterResetApi()._create_new_account(
|
||||
email="user@example.com",
|
||||
password="ValidPass123!",
|
||||
)
|
||||
|
||||
@patch("controllers.console.auth.email_register.AccountService.reset_login_error_rate_limit")
|
||||
@patch("controllers.console.auth.email_register.AccountService.login")
|
||||
@patch("controllers.console.auth.email_register.EmailRegisterResetApi._create_new_account")
|
||||
@patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback")
|
||||
@patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token")
|
||||
@patch("controllers.console.auth.email_register.AccountService.get_email_register_data")
|
||||
@patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1")
|
||||
def test_reset_creates_account_with_normalized_email(
|
||||
self,
|
||||
mock_extract_ip,
|
||||
mock_get_data,
|
||||
mock_revoke_token,
|
||||
mock_get_account,
|
||||
mock_create_account,
|
||||
mock_login,
|
||||
mock_reset_login_rate,
|
||||
app: Flask,
|
||||
def test_register_delegates_and_serializes_tokens(app: Flask) -> None:
|
||||
service = _service()
|
||||
service.register.return_value = AccountSessionTokens(
|
||||
access_token="access",
|
||||
refresh_token="refresh",
|
||||
csrf_token="csrf",
|
||||
)
|
||||
|
||||
with _request(
|
||||
app,
|
||||
service,
|
||||
path="/email-register",
|
||||
payload={
|
||||
"token": "verified-token",
|
||||
"new_password": "ValidPass123!",
|
||||
"password_confirm": "ValidPass123!",
|
||||
"language": "zh-Hans",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
):
|
||||
mock_get_data.return_value = {"phase": "register", "email": "Invitee@Example.com"}
|
||||
mock_create_account.return_value = Account(name="Invitee", email="invitee@example.com")
|
||||
token_pair = MagicMock()
|
||||
token_pair.model_dump.return_value = {"access_token": "a", "refresh_token": "r"}
|
||||
mock_login.return_value = token_pair
|
||||
mock_get_account.return_value = None
|
||||
response = EmailRegisterResetApi().post()
|
||||
|
||||
feature_flags = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_email_password_login=True,
|
||||
is_allow_register=True,
|
||||
)
|
||||
with (
|
||||
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
|
||||
):
|
||||
with app.test_request_context(
|
||||
"/email-register",
|
||||
method="POST",
|
||||
json={"token": "token-123", "new_password": "ValidPass123!", "password_confirm": "ValidPass123!"},
|
||||
):
|
||||
response = EmailRegisterResetApi().post()
|
||||
assert response == {
|
||||
"result": "success",
|
||||
"data": {"access_token": "access", "refresh_token": "refresh", "csrf_token": "csrf"},
|
||||
}
|
||||
assert service.register.call_args.kwargs == {
|
||||
"remote_ip": "127.0.0.1",
|
||||
"token": "verified-token",
|
||||
"new_password": "ValidPass123!",
|
||||
"password_confirm": "ValidPass123!",
|
||||
"language": "zh-Hans",
|
||||
"timezone": "Asia/Shanghai",
|
||||
}
|
||||
|
||||
assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}}
|
||||
mock_create_account.assert_called_once_with(
|
||||
email="invitee@example.com",
|
||||
password="ValidPass123!",
|
||||
timezone=None,
|
||||
language=None,
|
||||
ip_address="127.0.0.1",
|
||||
)
|
||||
mock_reset_login_rate.assert_called_once_with("invitee@example.com")
|
||||
mock_revoke_token.assert_called_once_with("token-123")
|
||||
mock_extract_ip.assert_called_once()
|
||||
|
||||
@patch("controllers.console.auth.email_register.AccountService.reset_login_error_rate_limit")
|
||||
@patch("controllers.console.auth.email_register.AccountService.login")
|
||||
@patch("controllers.console.auth.email_register.EmailRegisterResetApi._create_new_account")
|
||||
@patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback")
|
||||
@patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token")
|
||||
@patch("controllers.console.auth.email_register.AccountService.get_email_register_data")
|
||||
@patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1")
|
||||
def test_reset_passes_timezone_to_new_account(
|
||||
self,
|
||||
mock_extract_ip,
|
||||
mock_get_data,
|
||||
mock_revoke_token,
|
||||
mock_get_account,
|
||||
mock_create_account,
|
||||
mock_login,
|
||||
mock_reset_login_rate,
|
||||
app: Flask,
|
||||
@pytest.mark.parametrize(
|
||||
("service_error", "http_error"),
|
||||
[
|
||||
pytest.param(EmailRegistrationPasswordMismatchError(), PasswordMismatchError, id="password"),
|
||||
pytest.param(InvalidEmailRegistrationTokenError(), InvalidTokenError, id="token"),
|
||||
pytest.param(
|
||||
AccountNormalizedEmailAlreadyInUseError(),
|
||||
NormalizedEmailAlreadyInUseError,
|
||||
id="normalized-email-in-use",
|
||||
),
|
||||
pytest.param(AccountEmailAlreadyInUseError(), EmailAlreadyInUseError, id="email-in-use"),
|
||||
pytest.param(EmailRegistrationSeatsLimitError(), SeatsLimitExceeded, id="seat-limit"),
|
||||
pytest.param(AccountEmailFrozenError(), AccountInFreezeError, id="frozen"),
|
||||
pytest.param(AccountEmailDomainSuspendedError(), EmailDomainSuspendedError, id="suspended-domain"),
|
||||
],
|
||||
)
|
||||
def test_register_translates_application_errors(
|
||||
app: Flask,
|
||||
service_error: Exception,
|
||||
http_error: type[Exception],
|
||||
) -> None:
|
||||
service = _service()
|
||||
service.register.side_effect = service_error
|
||||
|
||||
with _request(
|
||||
app,
|
||||
service,
|
||||
path="/email-register",
|
||||
payload={
|
||||
"token": "verified-token",
|
||||
"new_password": "ValidPass123!",
|
||||
"password_confirm": "ValidPass123!",
|
||||
},
|
||||
):
|
||||
mock_get_data.return_value = {"phase": "register", "email": "Invitee@Example.com"}
|
||||
mock_create_account.return_value = Account(name="Invitee", email="invitee@example.com")
|
||||
token_pair = MagicMock()
|
||||
token_pair.model_dump.return_value = {"access_token": "a", "refresh_token": "r"}
|
||||
mock_login.return_value = token_pair
|
||||
mock_get_account.return_value = None
|
||||
with pytest.raises(http_error):
|
||||
EmailRegisterResetApi().post()
|
||||
|
||||
feature_flags = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_email_password_login=True,
|
||||
is_allow_register=True,
|
||||
|
||||
def test_reset_payload_rejects_invalid_timezone() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
EmailRegisterResetPayload.model_validate(
|
||||
{
|
||||
"token": "token-123",
|
||||
"new_password": "ValidPass123!",
|
||||
"password_confirm": "ValidPass123!",
|
||||
"timezone": "",
|
||||
}
|
||||
)
|
||||
with (
|
||||
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
|
||||
):
|
||||
with app.test_request_context(
|
||||
"/email-register",
|
||||
method="POST",
|
||||
json={
|
||||
"token": "token-123",
|
||||
"new_password": "ValidPass123!",
|
||||
"password_confirm": "ValidPass123!",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
):
|
||||
response = EmailRegisterResetApi().post()
|
||||
|
||||
assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}}
|
||||
mock_create_account.assert_called_once_with(
|
||||
email="invitee@example.com",
|
||||
password="ValidPass123!",
|
||||
timezone="Asia/Shanghai",
|
||||
language=None,
|
||||
ip_address="127.0.0.1",
|
||||
|
||||
def test_invalid_password_is_sanitized_by_real_error_handler(caplog: pytest.LogCaptureFixture) -> None:
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(console_bp)
|
||||
features = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.CLOUD,
|
||||
enable_email_password_login=True,
|
||||
is_allow_register=True,
|
||||
)
|
||||
password_marker = "SecretMarker"
|
||||
|
||||
with patch("controllers.console.flask_admission.FeatureService.get_system_features", return_value=features):
|
||||
response = app.test_client().post(
|
||||
"/console/api/email-register",
|
||||
json={
|
||||
"token": "verified-token",
|
||||
"new_password": password_marker,
|
||||
"password_confirm": password_marker,
|
||||
},
|
||||
)
|
||||
mock_reset_login_rate.assert_called_once_with("invitee@example.com")
|
||||
mock_revoke_token.assert_called_once_with("token-123")
|
||||
mock_extract_ip.assert_called_once()
|
||||
|
||||
@patch("controllers.console.auth.email_register.AccountService.reset_login_error_rate_limit")
|
||||
@patch("controllers.console.auth.email_register.AccountService.login")
|
||||
@patch("controllers.console.auth.email_register.EmailRegisterResetApi._create_new_account")
|
||||
@patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback")
|
||||
@patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token")
|
||||
@patch("controllers.console.auth.email_register.AccountService.get_email_register_data")
|
||||
@patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1")
|
||||
def test_reset_passes_language_to_new_account(
|
||||
self,
|
||||
mock_extract_ip,
|
||||
mock_get_data,
|
||||
mock_revoke_token,
|
||||
mock_get_account,
|
||||
mock_create_account,
|
||||
mock_login,
|
||||
mock_reset_login_rate,
|
||||
app: Flask,
|
||||
):
|
||||
mock_get_data.return_value = {"phase": "register", "email": "Invitee@Example.com"}
|
||||
mock_create_account.return_value = Account(name="Invitee", email="invitee@example.com")
|
||||
token_pair = MagicMock()
|
||||
token_pair.model_dump.return_value = {"access_token": "a", "refresh_token": "r"}
|
||||
mock_login.return_value = token_pair
|
||||
mock_get_account.return_value = None
|
||||
|
||||
feature_flags = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_email_password_login=True,
|
||||
is_allow_register=True,
|
||||
)
|
||||
with (
|
||||
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
|
||||
):
|
||||
with app.test_request_context(
|
||||
"/email-register",
|
||||
method="POST",
|
||||
json={
|
||||
"token": "token-123",
|
||||
"new_password": "ValidPass123!",
|
||||
"password_confirm": "ValidPass123!",
|
||||
"language": "zh-Hans",
|
||||
},
|
||||
):
|
||||
response = EmailRegisterResetApi().post()
|
||||
|
||||
assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}}
|
||||
mock_create_account.assert_called_once_with(
|
||||
email="invitee@example.com",
|
||||
password="ValidPass123!",
|
||||
timezone=None,
|
||||
language="zh-Hans",
|
||||
ip_address="127.0.0.1",
|
||||
)
|
||||
mock_reset_login_rate.assert_called_once_with("invitee@example.com")
|
||||
mock_revoke_token.assert_called_once_with("token-123")
|
||||
mock_extract_ip.assert_called_once()
|
||||
assert response.status_code == 422
|
||||
assert password_marker not in response.get_data(as_text=True)
|
||||
assert password_marker not in caplog.text
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.console.auth.email_register import EmailRegisterResetApi, EmailRegisterResetPayload
|
||||
from models.account import Account
|
||||
|
||||
|
||||
@patch("controllers.console.auth.email_register.AccountService.create_account_and_tenant")
|
||||
def test_create_new_account_uses_requested_language(mock_create_account):
|
||||
account = Account(name="Invitee", email="invitee@example.com")
|
||||
mock_create_account.return_value = account
|
||||
|
||||
result = EmailRegisterResetApi()._create_new_account(
|
||||
"invitee@example.com",
|
||||
"ValidPass123!",
|
||||
timezone="Asia/Shanghai",
|
||||
language="zh-Hans",
|
||||
)
|
||||
|
||||
assert result is account
|
||||
mock_create_account.assert_called_once_with(
|
||||
email="invitee@example.com",
|
||||
name="invitee@example.com",
|
||||
password="ValidPass123!",
|
||||
interface_language="zh-Hans",
|
||||
timezone="Asia/Shanghai",
|
||||
ip_address=None,
|
||||
check_normalized_email=True,
|
||||
session=ANY,
|
||||
)
|
||||
|
||||
|
||||
def test_reset_payload_rejects_invalid_timezone():
|
||||
with pytest.raises(ValidationError):
|
||||
EmailRegisterResetPayload.model_validate(
|
||||
{
|
||||
"token": "token-123",
|
||||
"new_password": "ValidPass123!",
|
||||
"password_confirm": "ValidPass123!",
|
||||
"timezone": "",
|
||||
}
|
||||
)
|
||||
@ -8,6 +8,7 @@ from unittest.mock import ANY, MagicMock, PropertyMock, call, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
|
||||
import services
|
||||
@ -44,7 +45,7 @@ from core.rag.index_processor.constant.index_type import IndexStructureType
|
||||
from core.rag.retrieval.retrieval_methods import RetrievalMethod
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from models.account import Account, TenantAccountRole
|
||||
from models.dataset import Dataset, DatasetQuery, Document
|
||||
from models.dataset import AppDatasetJoin, Dataset, DatasetPermission, DatasetQuery, Document, DocumentSegment
|
||||
from models.enums import CreatorUserRole, DataSourceType, DocumentCreatedFrom, IndexingStatus
|
||||
from models.model import ApiToken, App, AppMode, IconType, UploadFile
|
||||
from services.dataset_ref_service import DatasetRef
|
||||
@ -170,7 +171,29 @@ def make_document_status(**overrides) -> Document:
|
||||
return Document(**base)
|
||||
|
||||
|
||||
class TestDatasetList:
|
||||
def make_document_segment(*, position: int, completed: bool) -> DocumentSegment:
|
||||
return DocumentSegment(
|
||||
tenant_id="tenant-1",
|
||||
dataset_id="dataset-1",
|
||||
document_id="doc-1",
|
||||
position=position,
|
||||
content=f"segment {position}",
|
||||
word_count=2,
|
||||
tokens=2,
|
||||
created_by="account-1",
|
||||
completed_at=datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC) if completed else None,
|
||||
)
|
||||
|
||||
|
||||
class _UsesSQLiteSession:
|
||||
session: Session
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _inject_sqlite_session(self, sqlite_session: Session) -> None:
|
||||
self.session = sqlite_session
|
||||
|
||||
|
||||
class TestDatasetList(_UsesSQLiteSession):
|
||||
def _mock_user(self):
|
||||
user = make_account()
|
||||
return user
|
||||
@ -185,7 +208,7 @@ class TestDatasetList:
|
||||
patch.object(DatasetService, "get_datasets", return_value=(datasets, 1)),
|
||||
patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])),
|
||||
):
|
||||
resp, status = method(api, MagicMock(), "tenant-1", current_user)
|
||||
resp, status = method(api, self.session, "tenant-1", current_user)
|
||||
assert status == 200
|
||||
assert resp["total"] == 1
|
||||
assert resp["data"][0]["embedding_available"] is True
|
||||
@ -201,7 +224,7 @@ class TestDatasetList:
|
||||
method = unwrap(api.get)
|
||||
current_user = self._mock_user()
|
||||
dataset = make_dataset()
|
||||
session = MagicMock()
|
||||
session = self.session
|
||||
with app.test_request_context("/datasets"):
|
||||
with (
|
||||
patch.object(DatasetService, "get_datasets", return_value=([dataset], 1)),
|
||||
@ -222,7 +245,7 @@ class TestDatasetList:
|
||||
patch.object(DatasetService, "get_datasets_by_ids", return_value=(datasets, 2)) as by_ids_mock,
|
||||
patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])),
|
||||
):
|
||||
resp, status = method(api, MagicMock(), "tenant-1", current_user)
|
||||
resp, status = method(api, self.session, "tenant-1", current_user)
|
||||
by_ids_mock.assert_called_once()
|
||||
assert status == 200
|
||||
assert resp["total"] == 2
|
||||
@ -251,7 +274,7 @@ class TestDatasetList:
|
||||
return_value=permissions,
|
||||
) as get_permissions,
|
||||
):
|
||||
resp, status = method(api, MagicMock(), "tenant-1", current_user)
|
||||
resp, status = method(api, self.session, "tenant-1", current_user)
|
||||
get_permissions.assert_called_once_with("tenant-1", current_user.id, session=ANY)
|
||||
assert status == 200
|
||||
assert resp["data"][0]["permission_keys"] == ["dataset.acl.readonly", "dataset.acl.edit"]
|
||||
@ -281,7 +304,7 @@ class TestDatasetList:
|
||||
),
|
||||
patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])),
|
||||
):
|
||||
method(api, MagicMock(), "tenant-1", current_user)
|
||||
method(api, self.session, "tenant-1", current_user)
|
||||
assert get_datasets.call_args.kwargs["accessible_dataset_ids"] == []
|
||||
assert get_datasets.call_args.kwargs["include_own_datasets"] is False
|
||||
|
||||
@ -308,7 +331,7 @@ class TestDatasetList:
|
||||
),
|
||||
patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])),
|
||||
):
|
||||
method(api, MagicMock(), "tenant-1", current_user)
|
||||
method(api, self.session, "tenant-1", current_user)
|
||||
assert get_datasets.call_args.kwargs["accessible_dataset_ids"] is None
|
||||
|
||||
def test_get_restricted_whitelist_overrides_default_read_permission(
|
||||
@ -374,7 +397,7 @@ class TestDatasetList:
|
||||
),
|
||||
patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])),
|
||||
):
|
||||
method(api, MagicMock(), "tenant-1", current_user)
|
||||
method(api, self.session, "tenant-1", current_user)
|
||||
assert get_datasets.call_args.kwargs["accessible_dataset_ids"] == [
|
||||
"dataset-whitelist-only",
|
||||
]
|
||||
@ -399,9 +422,9 @@ class TestDatasetList:
|
||||
),
|
||||
patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])),
|
||||
):
|
||||
method(api, MagicMock(), "tenant-1", current_user)
|
||||
method(api, self.session, "tenant-1", current_user)
|
||||
session = get_datasets_by_ids.call_args.kwargs["session"]
|
||||
assert isinstance(session, MagicMock)
|
||||
assert session is self.session
|
||||
assert get_datasets_by_ids.call_args.args == (["dataset-1"], "tenant-1")
|
||||
assert get_datasets_by_ids.call_args.kwargs == {
|
||||
"user": current_user,
|
||||
@ -420,7 +443,7 @@ class TestDatasetList:
|
||||
patch.object(DatasetService, "get_datasets", return_value=(datasets, 1)),
|
||||
patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])),
|
||||
):
|
||||
resp, status = method(api, MagicMock(), "tenant-1", current_user)
|
||||
resp, status = method(api, self.session, "tenant-1", current_user)
|
||||
assert status == 200
|
||||
|
||||
def test_get_allows_legacy_weighted_score_without_weight_type(self, app: Flask):
|
||||
@ -453,7 +476,7 @@ class TestDatasetList:
|
||||
patch.object(DatasetService, "get_datasets", return_value=(datasets, 1)),
|
||||
patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])),
|
||||
):
|
||||
resp, status = method(api, MagicMock(), "tenant-1", current_user)
|
||||
resp, status = method(api, self.session, "tenant-1", current_user)
|
||||
assert status == 200
|
||||
assert resp["data"][0]["retrieval_model_dict"]["weights"]["weight_type"] is None
|
||||
|
||||
@ -467,7 +490,7 @@ class TestDatasetList:
|
||||
patch.object(DatasetService, "get_datasets", return_value=(datasets, 1)),
|
||||
patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])),
|
||||
):
|
||||
resp, status = method(api, MagicMock(), "tenant-1", current_user)
|
||||
resp, status = method(api, self.session, "tenant-1", current_user)
|
||||
assert status == 200
|
||||
retrieval_model = resp["data"][0]["retrieval_model_dict"]
|
||||
assert retrieval_model["search_method"] == "semantic_search"
|
||||
@ -491,7 +514,7 @@ class TestDatasetList:
|
||||
patch.object(DatasetService, "get_datasets", return_value=(datasets, 1)),
|
||||
patch.object(ProviderManager, "get_configurations", return_value=config),
|
||||
):
|
||||
resp, status = method(api, MagicMock(), "tenant-1", current_user)
|
||||
resp, status = method(api, self.session, "tenant-1", current_user)
|
||||
assert resp["data"][0]["embedding_available"] is False
|
||||
|
||||
def test_partial_members_permission(self, app: Flask):
|
||||
@ -499,8 +522,9 @@ class TestDatasetList:
|
||||
method = unwrap(api.get)
|
||||
current_user = self._mock_user()
|
||||
datasets = [make_dataset(permission="partial_members")]
|
||||
session = MagicMock()
|
||||
session.execute.return_value.all.return_value = [("ds-1", "u1")]
|
||||
session = self.session
|
||||
session.add(DatasetPermission(dataset_id="ds-1", account_id="u1", tenant_id="tenant-1"))
|
||||
session.flush()
|
||||
with app.test_request_context("/datasets"):
|
||||
with (
|
||||
patch.object(DatasetService, "get_datasets", return_value=(datasets, 1)),
|
||||
@ -510,7 +534,7 @@ class TestDatasetList:
|
||||
assert resp["data"][0]["partial_member_list"] == ["u1"]
|
||||
|
||||
|
||||
class TestDatasetListApiPost:
|
||||
class TestDatasetListApiPost(_UsesSQLiteSession):
|
||||
def test_post_success(self, app: Flask):
|
||||
api = DatasetListApi()
|
||||
method = unwrap(api.post)
|
||||
@ -522,7 +546,7 @@ class TestDatasetListApiPost:
|
||||
patch.object(type(console_ns), "payload", payload),
|
||||
patch.object(DatasetService, "create_empty_dataset", return_value=dataset),
|
||||
):
|
||||
_, status = method(api, DatasetCreatePayload(**payload), MagicMock(), "tenant-1", user)
|
||||
_, status = method(api, DatasetCreatePayload(**payload), self.session, "tenant-1", user)
|
||||
assert status == 201
|
||||
|
||||
def test_post_forbidden(self, app: Flask):
|
||||
@ -532,7 +556,7 @@ class TestDatasetListApiPost:
|
||||
user = make_account(TenantAccountRole.NORMAL)
|
||||
with app.test_request_context("/datasets", json=payload), patch.object(type(console_ns), "payload", payload):
|
||||
with pytest.raises(Forbidden):
|
||||
method(api, DatasetCreatePayload(**payload), MagicMock(), "tenant-1", user)
|
||||
method(api, DatasetCreatePayload(**payload), self.session, "tenant-1", user)
|
||||
|
||||
def test_post_duplicate_name(self, app: Flask):
|
||||
api = DatasetListApi()
|
||||
@ -547,14 +571,14 @@ class TestDatasetListApiPost:
|
||||
),
|
||||
):
|
||||
with pytest.raises(DatasetNameDuplicateError):
|
||||
method(api, DatasetCreatePayload(**payload), MagicMock(), "tenant-1", user)
|
||||
method(api, DatasetCreatePayload(**payload), self.session, "tenant-1", user)
|
||||
|
||||
def test_post_invalid_payload_missing_name(self, app: Flask):
|
||||
api = DatasetListApi()
|
||||
method = unwrap(api.post)
|
||||
with app.test_request_context("/datasets", json={}), patch.object(type(console_ns), "payload", {}):
|
||||
with pytest.raises(ValueError):
|
||||
method(api, DatasetCreatePayload(), MagicMock(), "tenant-1", make_account())
|
||||
method(api, DatasetCreatePayload(), self.session, "tenant-1", make_account())
|
||||
|
||||
def test_post_invalid_indexing_technique(self, app: Flask):
|
||||
api = DatasetListApi()
|
||||
@ -562,7 +586,7 @@ class TestDatasetListApiPost:
|
||||
payload = {"name": "bad", "indexing_technique": "invalid-tech"}
|
||||
with app.test_request_context("/datasets", json=payload), patch.object(type(console_ns), "payload", payload):
|
||||
with pytest.raises(ValueError, match="Invalid indexing technique"):
|
||||
method(api, DatasetCreatePayload(**payload), MagicMock(), "tenant-1", make_account())
|
||||
method(api, DatasetCreatePayload(**payload), self.session, "tenant-1", make_account())
|
||||
|
||||
def test_post_invalid_provider(self, app: Flask):
|
||||
api = DatasetListApi()
|
||||
@ -570,10 +594,10 @@ class TestDatasetListApiPost:
|
||||
payload = {"name": "bad", "provider": "unknown"}
|
||||
with app.test_request_context("/datasets", json=payload), patch.object(type(console_ns), "payload", payload):
|
||||
with pytest.raises(ValueError, match="Invalid provider"):
|
||||
method(api, DatasetCreatePayload(**payload), MagicMock(), "tenant-1", make_account())
|
||||
method(api, DatasetCreatePayload(**payload), self.session, "tenant-1", make_account())
|
||||
|
||||
|
||||
class TestDatasetApiGet:
|
||||
class TestDatasetApiGet(_UsesSQLiteSession):
|
||||
def test_get_success_basic(self, app: Flask):
|
||||
api = DatasetApi()
|
||||
method = unwrap(api.get)
|
||||
@ -588,7 +612,7 @@ class TestDatasetApiGet:
|
||||
patch("controllers.console.datasets.datasets.create_plugin_provider_manager") as provider_manager_mock,
|
||||
):
|
||||
provider_manager_mock.return_value.get_configurations.return_value.get_models.return_value = []
|
||||
data, status = method(api, MagicMock(), tenant_id, user, dataset_id)
|
||||
data, status = method(api, self.session, tenant_id, user, dataset_id)
|
||||
assert status == 200
|
||||
assert data["embedding_available"] is True
|
||||
|
||||
@ -597,7 +621,7 @@ class TestDatasetApiGet:
|
||||
api = DatasetApi()
|
||||
method = unwrap(api.get)
|
||||
dataset_id = "123e4567-e89b-12d3-a456-426614174000"
|
||||
user = MagicMock(id="account-1")
|
||||
user = make_account()
|
||||
tenant_id = "tenant-1"
|
||||
dataset = make_dataset(id=dataset_id)
|
||||
with (
|
||||
@ -619,7 +643,7 @@ class TestDatasetApiGet:
|
||||
patch("controllers.console.datasets.datasets.create_plugin_provider_manager") as provider_manager_mock,
|
||||
):
|
||||
provider_manager_mock.return_value.get_configurations.return_value.get_models.return_value = []
|
||||
data, status = method(api, MagicMock(), tenant_id, user, dataset_id)
|
||||
data, status = method(api, self.session, tenant_id, user, dataset_id)
|
||||
get_permissions.assert_called_once_with(tenant_id, user.id, dataset_id=dataset_id, session=ANY)
|
||||
assert status == 200
|
||||
assert data["permission_keys"] == ["dataset.acl.readonly", "dataset.acl.edit"]
|
||||
@ -636,7 +660,7 @@ class TestDatasetApiGet:
|
||||
patch("controllers.console.datasets.datasets.create_plugin_provider_manager") as provider_manager_mock,
|
||||
):
|
||||
provider_manager_mock.return_value.get_configurations.return_value.get_models.return_value = []
|
||||
data, status = method(api, MagicMock(), "tenant", make_account(), dataset_id)
|
||||
data, status = method(api, self.session, "tenant", make_account(), dataset_id)
|
||||
assert status == 200
|
||||
assert data["external_retrieval_model"] == {"top_k": 2, "score_threshold": 0.0, "score_threshold_enabled": None}
|
||||
|
||||
@ -649,7 +673,7 @@ class TestDatasetApiGet:
|
||||
patch.object(DatasetService, "get_dataset", return_value=None),
|
||||
):
|
||||
with pytest.raises(NotFound, match="Dataset not found"):
|
||||
method(api, MagicMock(), "tenant", make_account(), dataset_id)
|
||||
method(api, self.session, "tenant", make_account(), dataset_id)
|
||||
|
||||
def test_get_permission_denied(self, app: Flask):
|
||||
api = DatasetApi()
|
||||
@ -666,7 +690,7 @@ class TestDatasetApiGet:
|
||||
),
|
||||
):
|
||||
with pytest.raises(Forbidden, match="no access"):
|
||||
method(api, MagicMock(), "tenant", make_account(), dataset_id)
|
||||
method(api, self.session, "tenant", make_account(), dataset_id)
|
||||
|
||||
def test_get_high_quality_embedding_unavailable(self, app: Flask):
|
||||
api = DatasetApi()
|
||||
@ -687,7 +711,7 @@ class TestDatasetApiGet:
|
||||
patch("controllers.console.datasets.datasets.create_plugin_provider_manager") as provider_manager_mock,
|
||||
):
|
||||
provider_manager_mock.return_value.get_configurations.return_value.get_models.return_value = []
|
||||
data, _ = method(api, MagicMock(), tenant_id, user, dataset_id)
|
||||
data, _ = method(api, self.session, tenant_id, user, dataset_id)
|
||||
assert data["embedding_available"] is False
|
||||
|
||||
def test_get_partial_members_permission(self, app: Flask):
|
||||
@ -704,11 +728,11 @@ class TestDatasetApiGet:
|
||||
patch("controllers.console.datasets.datasets.create_plugin_provider_manager") as provider_manager_mock,
|
||||
):
|
||||
provider_manager_mock.return_value.get_configurations.return_value.get_models.return_value = []
|
||||
data, _ = method(api, MagicMock(), "tenant", make_account(), dataset_id)
|
||||
data, _ = method(api, self.session, "tenant", make_account(), dataset_id)
|
||||
assert data["partial_member_list"] == partial_members
|
||||
|
||||
|
||||
class TestDatasetApiPatch:
|
||||
class TestDatasetApiPatch(_UsesSQLiteSession):
|
||||
def test_patch_success_basic(self, app: Flask):
|
||||
api = DatasetApi()
|
||||
method = unwrap(api.patch)
|
||||
@ -725,7 +749,7 @@ class TestDatasetApiPatch:
|
||||
patch.object(DatasetService, "update_dataset", return_value=dataset),
|
||||
patch.object(DatasetPermissionService, "get_dataset_partial_member_list", return_value=[]),
|
||||
):
|
||||
result, status = method(api, DatasetUpdatePayload(), MagicMock(), tenant_id, user, dataset_id)
|
||||
result, status = method(api, DatasetUpdatePayload(), self.session, tenant_id, user, dataset_id)
|
||||
assert status == 200
|
||||
assert result["partial_member_list"] == []
|
||||
|
||||
@ -737,7 +761,7 @@ class TestDatasetApiPatch:
|
||||
patch.object(DatasetService, "get_dataset", return_value=None),
|
||||
):
|
||||
with pytest.raises(NotFound, match="Dataset not found"):
|
||||
method(api, DatasetUpdatePayload(), MagicMock(), "tenant-1", make_account(), "missing")
|
||||
method(api, DatasetUpdatePayload(), self.session, "tenant-1", make_account(), "missing")
|
||||
|
||||
def test_patch_permission_denied(self, app: Flask):
|
||||
api = DatasetApi()
|
||||
@ -752,7 +776,7 @@ class TestDatasetApiPatch:
|
||||
patch.object(DatasetPermissionService, "check_permission", side_effect=Forbidden("no permission")),
|
||||
):
|
||||
with pytest.raises(Forbidden):
|
||||
method(api, DatasetUpdatePayload(), MagicMock(), "tenant", make_account(), dataset_id)
|
||||
method(api, DatasetUpdatePayload(), self.session, "tenant", make_account(), dataset_id)
|
||||
|
||||
def test_patch_partial_members_update(self, app: Flask):
|
||||
api = DatasetApi()
|
||||
@ -769,7 +793,7 @@ class TestDatasetApiPatch:
|
||||
patch.object(DatasetPermissionService, "update_partial_member_list", return_value=None),
|
||||
patch.object(DatasetPermissionService, "get_dataset_partial_member_list", return_value=["u1", "u2"]),
|
||||
):
|
||||
result, _ = method(api, DatasetUpdatePayload(), MagicMock(), "tenant", make_account(), dataset_id)
|
||||
result, _ = method(api, DatasetUpdatePayload(), self.session, "tenant", make_account(), dataset_id)
|
||||
assert result["partial_member_list"] == ["u1", "u2"]
|
||||
|
||||
def test_patch_clear_partial_members(self, app: Flask):
|
||||
@ -787,11 +811,11 @@ class TestDatasetApiPatch:
|
||||
patch.object(DatasetPermissionService, "clear_partial_member_list", return_value=None),
|
||||
patch.object(DatasetPermissionService, "get_dataset_partial_member_list", return_value=[]),
|
||||
):
|
||||
result, _ = method(api, DatasetUpdatePayload(), MagicMock(), "tenant", make_account(), dataset_id)
|
||||
result, _ = method(api, DatasetUpdatePayload(), self.session, "tenant", make_account(), dataset_id)
|
||||
assert result["partial_member_list"] == []
|
||||
|
||||
|
||||
class TestDatasetApiDelete:
|
||||
class TestDatasetApiDelete(_UsesSQLiteSession):
|
||||
def test_delete_success(self, app: Flask):
|
||||
api = DatasetApi()
|
||||
method = unwrap(api.delete)
|
||||
@ -802,7 +826,7 @@ class TestDatasetApiDelete:
|
||||
patch.object(DatasetService, "delete_dataset", return_value=True),
|
||||
patch.object(DatasetPermissionService, "clear_partial_member_list", return_value=None),
|
||||
):
|
||||
result, status = method(api, MagicMock(), user, dataset_id)
|
||||
result, status = method(api, self.session, user, dataset_id)
|
||||
assert status == 204
|
||||
assert result == ""
|
||||
|
||||
@ -813,7 +837,7 @@ class TestDatasetApiDelete:
|
||||
user = make_account(TenantAccountRole.NORMAL)
|
||||
with app.test_request_context(f"/datasets/{dataset_id}"):
|
||||
with pytest.raises(Forbidden):
|
||||
method(api, MagicMock(), user, dataset_id)
|
||||
method(api, self.session, user, dataset_id)
|
||||
|
||||
def test_delete_dataset_not_found(self, app: Flask):
|
||||
api = DatasetApi()
|
||||
@ -825,7 +849,7 @@ class TestDatasetApiDelete:
|
||||
patch.object(DatasetService, "delete_dataset", return_value=False),
|
||||
):
|
||||
with pytest.raises(NotFound, match="Dataset not found"):
|
||||
method(api, MagicMock(), user, dataset_id)
|
||||
method(api, self.session, user, dataset_id)
|
||||
|
||||
def test_delete_dataset_in_use(self, app: Flask):
|
||||
api = DatasetApi()
|
||||
@ -837,10 +861,10 @@ class TestDatasetApiDelete:
|
||||
patch.object(DatasetService, "delete_dataset", side_effect=services.errors.dataset.DatasetInUseError()),
|
||||
):
|
||||
with pytest.raises(DatasetInUseError):
|
||||
method(api, MagicMock(), user, dataset_id)
|
||||
method(api, self.session, user, dataset_id)
|
||||
|
||||
|
||||
class TestDatasetUseCheckApi:
|
||||
class TestDatasetUseCheckApi(_UsesSQLiteSession):
|
||||
@pytest.mark.parametrize("is_using", [True, False])
|
||||
def test_get_use_check(self, app: Flask, is_using: bool):
|
||||
api = DatasetUseCheckApi()
|
||||
@ -848,7 +872,7 @@ class TestDatasetUseCheckApi:
|
||||
dataset_id = "dataset-id"
|
||||
dataset = make_dataset(id=dataset_id)
|
||||
current_user = make_account()
|
||||
session = MagicMock()
|
||||
session = self.session
|
||||
with (
|
||||
app.test_request_context(f"/datasets/{dataset_id}/use-check"),
|
||||
patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset) as get_dataset,
|
||||
@ -867,7 +891,7 @@ class TestDatasetUseCheckApi:
|
||||
api = DatasetUseCheckApi()
|
||||
method = unwrap(api.get)
|
||||
dataset = make_dataset(id="dataset-id")
|
||||
session = MagicMock()
|
||||
session = self.session
|
||||
with (
|
||||
app.test_request_context("/datasets/dataset-id/use-check"),
|
||||
patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset),
|
||||
@ -884,11 +908,11 @@ class TestDatasetUseCheckApi:
|
||||
"api_cls",
|
||||
[DatasetUseCheckApi, DatasetIndexingStatusApi, DatasetErrorDocs, DatasetAutoDisableLogApi],
|
||||
)
|
||||
def test_dataset_scoped_read_permission_denied(app: Flask, api_cls):
|
||||
def test_dataset_scoped_read_permission_denied(app: Flask, api_cls, sqlite_session: Session):
|
||||
api = api_cls()
|
||||
method = unwrap(api.get)
|
||||
dataset = make_dataset(id="dataset-1")
|
||||
session = MagicMock()
|
||||
session = sqlite_session
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset),
|
||||
@ -902,7 +926,7 @@ def test_dataset_scoped_read_permission_denied(app: Flask, api_cls):
|
||||
method(api, session, "tenant-1", make_account(), "dataset-1")
|
||||
|
||||
|
||||
class TestDatasetQueryApi:
|
||||
class TestDatasetQueryApi(_UsesSQLiteSession):
|
||||
def _query_record(self, index: int = 1) -> DatasetQuery:
|
||||
query = DatasetQuery(
|
||||
dataset_id="dataset-id",
|
||||
@ -929,7 +953,7 @@ class TestDatasetQueryApi:
|
||||
patch.object(DatasetService, "check_dataset_permission", return_value=None),
|
||||
patch.object(DatasetService, "get_dataset_queries", return_value=(queries, 2)),
|
||||
):
|
||||
response, status = method(api, MagicMock(), current_user, dataset_id)
|
||||
response, status = method(api, self.session, current_user, dataset_id)
|
||||
assert status == 200
|
||||
assert response["total"] == 2
|
||||
assert response["page"] == 1
|
||||
@ -952,24 +976,30 @@ class TestDatasetQueryApi:
|
||||
dataset = make_dataset(id="dataset-id")
|
||||
query = self._query_record()
|
||||
query.content = json.dumps([{"content_type": "image_query", "content": "file-1"}])
|
||||
upload_file = SimpleNamespace(
|
||||
id="file-1",
|
||||
upload_file = UploadFile(
|
||||
tenant_id="tenant-1",
|
||||
storage_type=StorageType.LOCAL,
|
||||
key="image.png",
|
||||
name="image.png",
|
||||
size=10,
|
||||
extension="png",
|
||||
mime_type="image/png",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="account-1",
|
||||
created_at=datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC),
|
||||
used=False,
|
||||
)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = upload_file
|
||||
upload_file.id = "file-1"
|
||||
session = self.session
|
||||
session.add(upload_file)
|
||||
session.flush()
|
||||
with (
|
||||
app.test_request_context("/datasets/queries"),
|
||||
patch.object(DatasetService, "get_dataset", return_value=dataset),
|
||||
patch.object(DatasetService, "check_dataset_permission", return_value=None),
|
||||
patch.object(DatasetService, "get_dataset_queries", return_value=([query], 1)),
|
||||
patch("models.dataset.db") as db_mock,
|
||||
patch("models.dataset.sign_upload_file_preview_url", return_value="signed-url"),
|
||||
):
|
||||
db_mock.session.scalar.return_value = upload_file
|
||||
response, status = method(api, session, make_account(), "dataset-id")
|
||||
|
||||
assert status == 200
|
||||
@ -987,8 +1017,7 @@ class TestDatasetQueryApi:
|
||||
},
|
||||
}
|
||||
]
|
||||
session.scalar.assert_called_once()
|
||||
db_mock.session.scalar.assert_not_called()
|
||||
assert session.get(UploadFile, "file-1") is upload_file
|
||||
|
||||
def test_get_queries_dataset_not_found(self, app: Flask):
|
||||
api = DatasetQueryApi()
|
||||
@ -1000,7 +1029,7 @@ class TestDatasetQueryApi:
|
||||
patch.object(DatasetService, "get_dataset", return_value=None),
|
||||
):
|
||||
with pytest.raises(NotFound, match="Dataset not found"):
|
||||
method(api, MagicMock(), current_user, dataset_id)
|
||||
method(api, self.session, current_user, dataset_id)
|
||||
|
||||
def test_get_queries_permission_denied(self, app: Flask):
|
||||
api = DatasetQueryApi()
|
||||
@ -1018,7 +1047,7 @@ class TestDatasetQueryApi:
|
||||
),
|
||||
):
|
||||
with pytest.raises(Forbidden):
|
||||
method(api, MagicMock(), current_user, dataset_id)
|
||||
method(api, self.session, current_user, dataset_id)
|
||||
|
||||
def test_get_queries_pagination_has_more(self, app: Flask):
|
||||
api = DatasetQueryApi()
|
||||
@ -1033,13 +1062,13 @@ class TestDatasetQueryApi:
|
||||
patch.object(DatasetService, "check_dataset_permission", return_value=None),
|
||||
patch.object(DatasetService, "get_dataset_queries", return_value=(queries, 40)),
|
||||
):
|
||||
response, status = method(api, MagicMock(), current_user, dataset_id)
|
||||
response, status = method(api, self.session, current_user, dataset_id)
|
||||
assert status == 200
|
||||
assert response["has_more"] is True
|
||||
assert len(response["data"]) == 20
|
||||
|
||||
|
||||
class TestDatasetIndexingEstimateApi:
|
||||
class TestDatasetIndexingEstimateApi(_UsesSQLiteSession):
|
||||
def _upload_file(self, *, tenant_id: str = "tenant-1", file_id: str = "file-1") -> UploadFile:
|
||||
upload_file = UploadFile(
|
||||
tenant_id=tenant_id,
|
||||
@ -1072,8 +1101,9 @@ class TestDatasetIndexingEstimateApi:
|
||||
method = unwrap(api.post)
|
||||
payload = self._base_payload()
|
||||
mock_file = self._upload_file()
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [mock_file]
|
||||
session = self.session
|
||||
session.add(mock_file)
|
||||
session.flush()
|
||||
|
||||
mock_response = IndexingEstimate(total_segments=100, preview=[])
|
||||
|
||||
@ -1102,8 +1132,7 @@ class TestDatasetIndexingEstimateApi:
|
||||
api = DatasetIndexingEstimateApi()
|
||||
method = unwrap(api.post)
|
||||
payload = self._base_payload()
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = None
|
||||
session = self.session
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload),
|
||||
@ -1122,8 +1151,9 @@ class TestDatasetIndexingEstimateApi:
|
||||
method = unwrap(api.post)
|
||||
mock_file = self._upload_file()
|
||||
payload = self._base_payload()
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [mock_file]
|
||||
session = self.session
|
||||
session.add(mock_file)
|
||||
session.flush()
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload),
|
||||
@ -1146,8 +1176,9 @@ class TestDatasetIndexingEstimateApi:
|
||||
method = unwrap(api.post)
|
||||
mock_file = self._upload_file()
|
||||
payload = self._base_payload()
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [mock_file]
|
||||
session = self.session
|
||||
session.add(mock_file)
|
||||
session.flush()
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload),
|
||||
@ -1170,8 +1201,9 @@ class TestDatasetIndexingEstimateApi:
|
||||
method = unwrap(api.post)
|
||||
mock_file = self._upload_file()
|
||||
payload = self._base_payload()
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [mock_file]
|
||||
session = self.session
|
||||
session.add(mock_file)
|
||||
session.flush()
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload),
|
||||
@ -1189,16 +1221,16 @@ class TestDatasetIndexingEstimateApi:
|
||||
)
|
||||
|
||||
|
||||
class TestDatasetRelatedAppListApi:
|
||||
class TestDatasetRelatedAppListApi(_UsesSQLiteSession):
|
||||
def test_get_success(self, app: Flask):
|
||||
api = DatasetRelatedAppListApi()
|
||||
method = unwrap(api.get)
|
||||
dataset = make_dataset(id="dataset-1")
|
||||
app1 = make_related_app(id="app-1", name="App 1")
|
||||
app2 = make_related_app(id="app-2", name="App 2")
|
||||
join1 = MagicMock(app_id="app-1")
|
||||
join2 = MagicMock(app_id="app-2")
|
||||
session = MagicMock()
|
||||
join1 = AppDatasetJoin(app_id="app-1", dataset_id="dataset-1")
|
||||
join2 = AppDatasetJoin(app_id="app-2", dataset_id="dataset-1")
|
||||
session = self.session
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch("controllers.console.datasets.datasets.DatasetService.get_dataset", return_value=dataset),
|
||||
@ -1251,7 +1283,7 @@ class TestDatasetRelatedAppListApi:
|
||||
patch("controllers.console.datasets.datasets.DatasetService.get_dataset", return_value=None),
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, MagicMock(), make_account(), "dataset-1")
|
||||
method(api, self.session, make_account(), "dataset-1")
|
||||
|
||||
def test_get_permission_denied(self, app: Flask):
|
||||
api = DatasetRelatedAppListApi()
|
||||
@ -1266,16 +1298,16 @@ class TestDatasetRelatedAppListApi:
|
||||
),
|
||||
):
|
||||
with pytest.raises(Forbidden):
|
||||
method(api, MagicMock(), make_account(), "dataset-1")
|
||||
method(api, self.session, make_account(), "dataset-1")
|
||||
|
||||
def test_get_filters_none_apps(self, app: Flask):
|
||||
api = DatasetRelatedAppListApi()
|
||||
method = unwrap(api.get)
|
||||
dataset = make_dataset(id="dataset-1")
|
||||
app1 = make_related_app()
|
||||
join1 = MagicMock(app_id="app-1")
|
||||
join2 = MagicMock(app_id="app-2")
|
||||
session = MagicMock()
|
||||
join1 = AppDatasetJoin(app_id="app-1", dataset_id="dataset-1")
|
||||
join2 = AppDatasetJoin(app_id="app-2", dataset_id="dataset-1")
|
||||
session = self.session
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch("controllers.console.datasets.datasets.DatasetService.get_dataset", return_value=dataset),
|
||||
@ -1303,26 +1335,17 @@ class TestDatasetRelatedAppListApi:
|
||||
]
|
||||
|
||||
|
||||
class TestDatasetIndexingStatusApi:
|
||||
class TestDatasetIndexingStatusApi(_UsesSQLiteSession):
|
||||
def test_get_success_with_documents(self, app: Flask):
|
||||
api = DatasetIndexingStatusApi()
|
||||
method = unwrap(api.get)
|
||||
dataset = make_dataset(id="dataset-1")
|
||||
current_user = make_account()
|
||||
document = MagicMock()
|
||||
document.id = "doc-1"
|
||||
document.indexing_status = "completed"
|
||||
document.processing_started_at = None
|
||||
document.parsing_completed_at = None
|
||||
document.cleaning_completed_at = None
|
||||
document.splitting_completed_at = None
|
||||
document.completed_at = None
|
||||
document.paused_at = None
|
||||
document.error = None
|
||||
document.stopped_at = None
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [document]
|
||||
session.scalar.return_value = 3
|
||||
document = make_document_status()
|
||||
session = self.session
|
||||
session.add(document)
|
||||
session.add_all([make_document_segment(position=position, completed=True) for position in range(1, 4)])
|
||||
session.flush()
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset) as get_dataset,
|
||||
@ -1337,16 +1360,13 @@ class TestDatasetIndexingStatusApi:
|
||||
assert item["total_segments"] == 3
|
||||
get_dataset.assert_called_once_with("dataset-1", "tenant-1", session=session)
|
||||
check_permission.assert_called_once_with(dataset, current_user, session)
|
||||
assert {"dataset-1", "tenant-1"} <= set(session.scalars.call_args.args[0].compile().params.values())
|
||||
for segment_count_call in session.scalar.call_args_list:
|
||||
assert {"dataset-1", "tenant-1", "doc-1"} <= set(segment_count_call.args[0].compile().params.values())
|
||||
assert session.get(Document, "doc-1") is document
|
||||
|
||||
def test_get_success_no_documents(self, app: Flask):
|
||||
api = DatasetIndexingStatusApi()
|
||||
method = unwrap(api.get)
|
||||
dataset = make_dataset(id="dataset-1")
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = []
|
||||
session = self.session
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset),
|
||||
@ -1360,20 +1380,11 @@ class TestDatasetIndexingStatusApi:
|
||||
api = DatasetIndexingStatusApi()
|
||||
method = unwrap(api.get)
|
||||
dataset = make_dataset(id="dataset-1")
|
||||
document = MagicMock()
|
||||
document.id = "doc-1"
|
||||
document.indexing_status = "indexing"
|
||||
document.processing_started_at = None
|
||||
document.parsing_completed_at = None
|
||||
document.cleaning_completed_at = None
|
||||
document.splitting_completed_at = None
|
||||
document.completed_at = None
|
||||
document.paused_at = None
|
||||
document.error = None
|
||||
document.stopped_at = None
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [document]
|
||||
session.scalar.side_effect = [2, 5]
|
||||
document = make_document_status(indexing_status=IndexingStatus.INDEXING)
|
||||
session = self.session
|
||||
session.add(document)
|
||||
session.add_all([make_document_segment(position=position, completed=position <= 2) for position in range(1, 6)])
|
||||
session.flush()
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset),
|
||||
@ -1386,7 +1397,7 @@ class TestDatasetIndexingStatusApi:
|
||||
assert item["total_segments"] == 5
|
||||
|
||||
|
||||
class TestDatasetApiKeyApi:
|
||||
class TestDatasetApiKeyApi(_UsesSQLiteSession):
|
||||
def test_get_api_keys_success(self, app: Flask):
|
||||
api = DatasetApiKeyApi()
|
||||
method = unwrap(api.get)
|
||||
@ -1404,8 +1415,11 @@ class TestDatasetApiKeyApi:
|
||||
last_used_at=None,
|
||||
created_at=None,
|
||||
)
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [mock_key_1, mock_key_2]
|
||||
session = self.session
|
||||
mock_key_1.tenant_id = "tenant-1"
|
||||
mock_key_2.tenant_id = "tenant-1"
|
||||
session.add_all([mock_key_1, mock_key_2])
|
||||
session.flush()
|
||||
with app.test_request_context("/"):
|
||||
response = method(api, session, "tenant-1")
|
||||
assert "data" in response
|
||||
@ -1418,30 +1432,31 @@ class TestDatasetApiKeyApi:
|
||||
def test_post_create_api_key_success(self, app: Flask):
|
||||
api = DatasetApiKeyApi()
|
||||
method = unwrap(api.post)
|
||||
mock_token = MagicMock()
|
||||
mock_token.id = "new-key-id"
|
||||
mock_token.last_used_at = None
|
||||
mock_token.created_at = datetime.datetime(2024, 1, 1, 0, 0, 0, tzinfo=datetime.UTC)
|
||||
mock_api_token_cls = MagicMock()
|
||||
mock_api_token_cls.return_value = mock_token
|
||||
mock_api_token_cls.generate_api_key.return_value = "dataset-abc123"
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = 3
|
||||
with app.test_request_context("/"), patch("controllers.console.datasets.datasets.ApiToken", mock_api_token_cls):
|
||||
session = self.session
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(ApiToken, "generate_api_key", return_value="dataset-abc123") as generate_api_key,
|
||||
):
|
||||
response, status = method(api, session, "tenant-1")
|
||||
assert status == 200
|
||||
assert isinstance(response, dict)
|
||||
assert response["id"] == "new-key-id"
|
||||
assert response["token"] == "dataset-abc123"
|
||||
assert response["type"] == "dataset"
|
||||
assert response["created_at"] is not None
|
||||
mock_api_token_cls.generate_api_key.assert_called_once_with("dataset-", 24, session=session)
|
||||
generate_api_key.assert_called_once_with("dataset-", 24, session=session)
|
||||
assert session.get(ApiToken, response["id"]).token == "dataset-abc123"
|
||||
|
||||
def test_post_exceed_max_keys(self, app: Flask):
|
||||
api = DatasetApiKeyApi()
|
||||
method = unwrap(api.post)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = 10
|
||||
session = self.session
|
||||
session.add_all(
|
||||
[
|
||||
ApiToken(id=f"key-{index}", tenant_id="tenant-1", type="dataset", token=f"ds-{index}")
|
||||
for index in range(10)
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
with app.test_request_context("/"):
|
||||
with pytest.raises(BadRequest) as exc_info:
|
||||
method(api, session, "tenant-1")
|
||||
@ -1452,36 +1467,42 @@ class TestDatasetApiKeyApi:
|
||||
}
|
||||
|
||||
|
||||
class TestDatasetApiDeleteApi:
|
||||
class TestDatasetApiDeleteApi(_UsesSQLiteSession):
|
||||
def test_delete_success(self, app: Flask):
|
||||
api = DatasetApiDeleteApi()
|
||||
method = unwrap(api.delete)
|
||||
mock_key = MagicMock()
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = mock_key
|
||||
with app.test_request_context("/"):
|
||||
session = self.session
|
||||
key = ApiToken(id="api-key-id", tenant_id="tenant-1", type="dataset", token="dataset-secret")
|
||||
session.add(key)
|
||||
session.flush()
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch("controllers.console.datasets.datasets.ApiTokenCache.delete") as delete_cache,
|
||||
):
|
||||
response, status = method(api, session, "tenant-1", "api-key-id")
|
||||
assert status == 204
|
||||
assert response == ""
|
||||
delete_cache.assert_called_once()
|
||||
session.flush()
|
||||
assert session.get(ApiToken, "api-key-id") is None
|
||||
|
||||
def test_delete_key_not_found(self, app: Flask):
|
||||
api = DatasetApiDeleteApi()
|
||||
method = unwrap(api.delete)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = None
|
||||
session = self.session
|
||||
with app.test_request_context("/"):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, session, "tenant-1", "api-key-id")
|
||||
|
||||
|
||||
class TestDatasetEnableApiApi:
|
||||
class TestDatasetEnableApiApi(_UsesSQLiteSession):
|
||||
@pytest.mark.parametrize(("status_value", "enabled"), [("enable", True), ("disable", False)])
|
||||
def test_update_api_status(self, app: Flask, status_value: str, enabled: bool):
|
||||
api = DatasetEnableApiApi()
|
||||
method = unwrap(api.post)
|
||||
dataset = make_dataset(id="dataset-1")
|
||||
current_user = make_account()
|
||||
session = MagicMock()
|
||||
session = self.session
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset) as get_dataset,
|
||||
@ -1499,7 +1520,7 @@ class TestDatasetEnableApiApi:
|
||||
api = DatasetEnableApiApi()
|
||||
method = unwrap(api.post)
|
||||
dataset = make_dataset(id="dataset-1")
|
||||
session = MagicMock()
|
||||
session = self.session
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset),
|
||||
@ -1582,7 +1603,7 @@ class TestDatasetRetrievalSettingApi:
|
||||
]
|
||||
|
||||
|
||||
class TestDatasetRetrievalSettingMockApi:
|
||||
class TestDatasetRetrievalSettingMockApi(_UsesSQLiteSession):
|
||||
def test_get_success(self, app: Flask):
|
||||
api = DatasetRetrievalSettingMockApi()
|
||||
method = unwrap(api.get)
|
||||
@ -1597,14 +1618,14 @@ class TestDatasetRetrievalSettingMockApi:
|
||||
assert response["retrieval_method"] == ["semantic"]
|
||||
|
||||
|
||||
class TestDatasetErrorDocs:
|
||||
class TestDatasetErrorDocs(_UsesSQLiteSession):
|
||||
def test_get_success(self, app: Flask):
|
||||
api = DatasetErrorDocs()
|
||||
method = unwrap(api.get)
|
||||
dataset = make_dataset(id="dataset-1")
|
||||
error_doc = make_document_status(id="error-doc", indexing_status=IndexingStatus.ERROR, error="failed")
|
||||
current_user = make_account()
|
||||
session = MagicMock()
|
||||
session = self.session
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset) as get_dataset,
|
||||
@ -1624,7 +1645,7 @@ class TestDatasetErrorDocs:
|
||||
def test_get_dataset_not_found(self, app: Flask):
|
||||
api = DatasetErrorDocs()
|
||||
method = unwrap(api.get)
|
||||
session = MagicMock()
|
||||
session = self.session
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(DatasetService, "get_dataset_for_tenant", return_value=None) as get_dataset,
|
||||
@ -1634,7 +1655,7 @@ class TestDatasetErrorDocs:
|
||||
get_dataset.assert_called_once_with("dataset-1", "tenant-1", session=session)
|
||||
|
||||
|
||||
class TestDatasetPermissionUserListApi:
|
||||
class TestDatasetPermissionUserListApi(_UsesSQLiteSession):
|
||||
def test_get_success(self, app: Flask):
|
||||
api = DatasetPermissionUserListApi()
|
||||
method = unwrap(api.get)
|
||||
@ -1649,7 +1670,7 @@ class TestDatasetPermissionUserListApi:
|
||||
return_value=users,
|
||||
),
|
||||
):
|
||||
response, status = method(api, MagicMock(), make_account(), "dataset-1")
|
||||
response, status = method(api, self.session, make_account(), "dataset-1")
|
||||
assert status == 200
|
||||
assert response["data"] == users
|
||||
|
||||
@ -1666,17 +1687,17 @@ class TestDatasetPermissionUserListApi:
|
||||
),
|
||||
):
|
||||
with pytest.raises(Forbidden):
|
||||
method(api, MagicMock(), make_account(), "dataset-1")
|
||||
method(api, self.session, make_account(), "dataset-1")
|
||||
|
||||
|
||||
class TestDatasetAutoDisableLogApi:
|
||||
class TestDatasetAutoDisableLogApi(_UsesSQLiteSession):
|
||||
def test_get_success(self, app: Flask):
|
||||
api = DatasetAutoDisableLogApi()
|
||||
method = unwrap(api.get)
|
||||
dataset = make_dataset(id="dataset-1")
|
||||
logs = {"document_ids": ["doc-1"], "count": 1}
|
||||
current_user = make_account()
|
||||
session = MagicMock()
|
||||
session = self.session
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset) as get_dataset,
|
||||
@ -1693,7 +1714,7 @@ class TestDatasetAutoDisableLogApi:
|
||||
def test_get_dataset_not_found(self, app: Flask):
|
||||
api = DatasetAutoDisableLogApi()
|
||||
method = unwrap(api.get)
|
||||
session = MagicMock()
|
||||
session = self.session
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(DatasetService, "get_dataset_for_tenant", return_value=None) as get_dataset,
|
||||
|
||||
@ -0,0 +1,77 @@
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from controllers.console.notification import (
|
||||
DismissNotificationPayload,
|
||||
NotificationApi,
|
||||
NotificationDismissApi,
|
||||
)
|
||||
from machinery.context import RequestContext
|
||||
from services.entities.notification_entities import NotificationItem, NotificationResult
|
||||
|
||||
|
||||
def _request_context() -> RequestContext:
|
||||
return RequestContext(
|
||||
request_id="request-1",
|
||||
trace_id="trace-1",
|
||||
account_id="account-1",
|
||||
active_workspace_id="workspace-1",
|
||||
)
|
||||
|
||||
|
||||
def test_get_notification_delegates_and_serializes_result() -> None:
|
||||
service = Mock()
|
||||
service.get_active.return_value = NotificationResult(
|
||||
should_show=True,
|
||||
notifications=(
|
||||
NotificationItem(
|
||||
notification_id="notification-1",
|
||||
frequency="once",
|
||||
lang="en-US",
|
||||
title="Title",
|
||||
subtitle="Subtitle",
|
||||
body="Body",
|
||||
title_pic_url="https://example.com/title.png",
|
||||
),
|
||||
),
|
||||
)
|
||||
services = SimpleNamespace(notifications=service)
|
||||
api = NotificationApi()
|
||||
method = unwrap(api.get)
|
||||
context = _request_context()
|
||||
|
||||
with patch("controllers.console.notification.application_services", return_value=services):
|
||||
result, status = method(api, context)
|
||||
|
||||
assert status == 200
|
||||
assert result == {
|
||||
"should_show": True,
|
||||
"notifications": [
|
||||
{
|
||||
"notification_id": "notification-1",
|
||||
"frequency": "once",
|
||||
"lang": "en-US",
|
||||
"title": "Title",
|
||||
"subtitle": "Subtitle",
|
||||
"body": "Body",
|
||||
"title_pic_url": "https://example.com/title.png",
|
||||
}
|
||||
],
|
||||
}
|
||||
service.get_active.assert_called_once_with(context)
|
||||
|
||||
|
||||
def test_dismiss_notification_delegates_with_stable_account_context() -> None:
|
||||
service = Mock()
|
||||
services = SimpleNamespace(notifications=service)
|
||||
api = NotificationDismissApi()
|
||||
method = unwrap(api.post)
|
||||
context = _request_context()
|
||||
|
||||
with patch("controllers.console.notification.application_services", return_value=services):
|
||||
result, status = method(api, DismissNotificationPayload(notification_id="notification-1"), context)
|
||||
|
||||
assert status == 200
|
||||
assert result == {"result": "success"}
|
||||
service.dismiss.assert_called_once_with(context, "notification-1")
|
||||
@ -2,47 +2,48 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from inspect import unwrap
|
||||
from unittest.mock import Mock
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.console.onboarding import (
|
||||
StepByStepTourStateApi,
|
||||
StepByStepTourStatePatchPayload,
|
||||
StepByStepTourStateResponse,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from models.account import Account, AccountStatus
|
||||
from services.step_by_step_tour_service import StepByStepTourService
|
||||
from machinery.context import RequestContext
|
||||
from services.entities.onboarding_entities import StepByStepTourPatch, StepByStepTourResult
|
||||
|
||||
|
||||
def _account() -> Account:
|
||||
account = Account(name="User", email="user@example.com", status=AccountStatus.ACTIVE)
|
||||
account.id = "account-1"
|
||||
return account
|
||||
def _request_context() -> RequestContext:
|
||||
return RequestContext(
|
||||
request_id="request-1",
|
||||
trace_id="trace-1",
|
||||
account_id="account-1",
|
||||
active_workspace_id="workspace-1",
|
||||
)
|
||||
|
||||
|
||||
def _state_response() -> dict[str, object]:
|
||||
return {
|
||||
"first_workspace_id": "workspace-1",
|
||||
"skipped": False,
|
||||
"completed_task_ids": ["home"],
|
||||
"manually_enabled_workspace_ids": [],
|
||||
"manually_disabled_workspace_ids": [],
|
||||
"updated_at": datetime(2026, 6, 28, tzinfo=UTC),
|
||||
}
|
||||
def _state_result() -> StepByStepTourResult:
|
||||
return StepByStepTourResult(
|
||||
first_workspace_id="workspace-1",
|
||||
completed_task_ids=("home",),
|
||||
updated_at=datetime(2026, 6, 28, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_get_step_by_step_tour_state(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
get_state = Mock(return_value=_state_response())
|
||||
monkeypatch.setattr(StepByStepTourService, "get_state", get_state)
|
||||
|
||||
def test_get_step_by_step_tour_state_delegates_with_request_context() -> None:
|
||||
service = Mock()
|
||||
service.get_state.return_value = _state_result()
|
||||
services = SimpleNamespace(step_by_step_tour=service)
|
||||
api = StepByStepTourStateApi()
|
||||
method = unwrap(api.get)
|
||||
context = _request_context()
|
||||
|
||||
with app.test_request_context("/console/api/onboarding/step-by-step-tour/state", method="GET"):
|
||||
result = method(api, "workspace-1", _account())
|
||||
with patch("controllers.console.onboarding.application_services", return_value=services):
|
||||
result = method(api, context)
|
||||
|
||||
assert result == {
|
||||
"first_workspace_id": "workspace-1",
|
||||
@ -52,35 +53,26 @@ def test_get_step_by_step_tour_state(app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
"manually_disabled_workspace_ids": [],
|
||||
"updated_at": "2026-06-28T00:00:00Z",
|
||||
}
|
||||
get_state.assert_called_once()
|
||||
assert get_state.call_args.kwargs["current_tenant_id"] == "workspace-1"
|
||||
assert get_state.call_args.kwargs["session"] is db.session
|
||||
service.get_state.assert_called_once_with(context)
|
||||
|
||||
|
||||
def test_patch_step_by_step_tour_state_passes_action_payload(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
patch_state = Mock(return_value=_state_response())
|
||||
monkeypatch.setattr(StepByStepTourService, "patch_state", patch_state)
|
||||
|
||||
def test_patch_step_by_step_tour_state_maps_transport_payload_to_command() -> None:
|
||||
service = Mock()
|
||||
service.patch_state.return_value = _state_result()
|
||||
services = SimpleNamespace(step_by_step_tour=service)
|
||||
api = StepByStepTourStateApi()
|
||||
method = unwrap(api.patch)
|
||||
payload = {"action": "complete_task", "task_id": "studio"}
|
||||
context = _request_context()
|
||||
payload = StepByStepTourStatePatchPayload.model_validate({"action": "complete_task", "task_id": "studio"})
|
||||
|
||||
req_data = StepByStepTourStatePatchPayload.model_validate(payload)
|
||||
with app.test_request_context(
|
||||
"/console/api/onboarding/step-by-step-tour/state",
|
||||
method="PATCH",
|
||||
json=payload,
|
||||
):
|
||||
result = method(api, req_data, "workspace-1", _account())
|
||||
with patch("controllers.console.onboarding.application_services", return_value=services):
|
||||
result = method(api, payload, context)
|
||||
|
||||
assert result["completed_task_ids"] == ["home"]
|
||||
patch_state.assert_called_once()
|
||||
assert patch_state.call_args.kwargs["current_tenant_id"] == "workspace-1"
|
||||
assert patch_state.call_args.kwargs["patch"] == payload
|
||||
assert patch_state.call_args.kwargs["session"] is db.session
|
||||
service.patch_state.assert_called_once_with(
|
||||
context,
|
||||
StepByStepTourPatch(action="complete_task", task_id="studio"),
|
||||
)
|
||||
|
||||
|
||||
def test_patch_payload_rejects_non_action_fields() -> None:
|
||||
@ -96,3 +88,21 @@ def test_patch_payload_rejects_task_id_without_task_action() -> None:
|
||||
def test_patch_payload_requires_action() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
StepByStepTourStatePatchPayload.model_validate({"task_id": "home"})
|
||||
|
||||
|
||||
def test_step_by_step_tour_schemas_preserve_enum_values() -> None:
|
||||
patch_schema = StepByStepTourStatePatchPayload.model_json_schema()
|
||||
action_schema = patch_schema["properties"]["action"]
|
||||
task_id_schema = patch_schema["properties"]["task_id"]
|
||||
task_id_values = next(candidate["enum"] for candidate in task_id_schema["anyOf"] if "enum" in candidate)
|
||||
response_schema = StepByStepTourStateResponse.model_json_schema()
|
||||
|
||||
assert set(action_schema["enum"]) == {
|
||||
"skip",
|
||||
"complete_task",
|
||||
"uncomplete_task",
|
||||
"enable_current_workspace",
|
||||
"disable_current_workspace",
|
||||
}
|
||||
assert set(task_id_values) == {"home", "studio", "knowledge", "integration"}
|
||||
assert set(response_schema["properties"]["completed_task_ids"]["items"]["enum"]) == set(task_id_values)
|
||||
|
||||
@ -196,6 +196,64 @@ class TestCurrentContextInjection:
|
||||
login_required.assert_called_once()
|
||||
account_initialization_required.assert_called_once()
|
||||
|
||||
def test_console_email_registration_admission_checks_features_once(self):
|
||||
features = SimpleNamespace(enable_email_password_login=True, is_allow_register=True)
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.flask_admission.setup_required", side_effect=lambda view: view
|
||||
) as setup_required,
|
||||
patch(
|
||||
"controllers.console.flask_admission.FeatureService.get_system_features",
|
||||
return_value=features,
|
||||
) as get_system_features,
|
||||
):
|
||||
|
||||
class Handler:
|
||||
@flask_admission.console_email_registration_admission
|
||||
def post(self):
|
||||
return "ok"
|
||||
|
||||
with Flask(__name__).test_request_context():
|
||||
result = Handler().post()
|
||||
|
||||
assert result == "ok"
|
||||
setup_required.assert_called_once()
|
||||
get_system_features.assert_called_once_with()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("enable_email_password_login", "is_allow_register"),
|
||||
[
|
||||
pytest.param(False, True, id="password-login-disabled"),
|
||||
pytest.param(True, False, id="registration-disabled"),
|
||||
],
|
||||
)
|
||||
def test_console_email_registration_admission_rejects_disabled_features(
|
||||
self,
|
||||
enable_email_password_login: bool,
|
||||
is_allow_register: bool,
|
||||
) -> None:
|
||||
features = SimpleNamespace(
|
||||
enable_email_password_login=enable_email_password_login,
|
||||
is_allow_register=is_allow_register,
|
||||
)
|
||||
with (
|
||||
patch("controllers.console.flask_admission.setup_required", side_effect=lambda view: view),
|
||||
patch(
|
||||
"controllers.console.flask_admission.FeatureService.get_system_features",
|
||||
return_value=features,
|
||||
),
|
||||
):
|
||||
|
||||
class Handler:
|
||||
@flask_admission.console_email_registration_admission
|
||||
def post(self):
|
||||
return "ok"
|
||||
|
||||
with Flask(__name__).test_request_context(), pytest.raises(HTTPException) as exc_info:
|
||||
Handler().post()
|
||||
|
||||
assert exc_info.value.code == 403
|
||||
|
||||
def test_console_account_admission_preserves_route_kwarg_named_request_context(self):
|
||||
current_user = make_account()
|
||||
|
||||
|
||||
@ -8,8 +8,12 @@ import pytest
|
||||
from werkzeug.exceptions import TooManyRequests
|
||||
|
||||
from controllers.openapi.app_run import _translate_service_errors
|
||||
from controllers.service_api.app.error import TriggerWorkflowServiceModeUnavailableError
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from core.errors.error import AppInvokeQuotaExceededError
|
||||
from services.errors.app import (
|
||||
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
|
||||
)
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
|
||||
|
||||
@ -27,3 +31,11 @@ def test_translate_maps_workflow_quota_to_rate_limit_error():
|
||||
raise InvokeRateLimitError("workflow quota exhausted")
|
||||
assert exc.value.error_code == "rate_limit_error"
|
||||
assert exc.value.code == 429
|
||||
|
||||
|
||||
def test_translate_maps_trigger_workflow_to_stable_unavailable_error():
|
||||
with pytest.raises(TriggerWorkflowServiceModeUnavailableError) as exc:
|
||||
with _translate_service_errors():
|
||||
raise TriggerWorkflowServiceModeUnavailableServiceError()
|
||||
assert exc.value.error_code == "trigger_workflow_service_mode_unavailable"
|
||||
assert exc.value.code == 403
|
||||
|
||||
@ -28,7 +28,11 @@ from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
|
||||
from controllers.service_api.app.error import NotWorkflowAppError, WorkflowVersionExecutionNotAllowedError
|
||||
from controllers.service_api.app.error import (
|
||||
NotWorkflowAppError,
|
||||
TriggerWorkflowServiceModeUnavailableError,
|
||||
WorkflowVersionExecutionNotAllowedError,
|
||||
)
|
||||
from controllers.service_api.app.workflow import (
|
||||
AppQueueManager,
|
||||
GraphEngineManager,
|
||||
@ -51,7 +55,13 @@ from models.model import App, AppMode, EndUser
|
||||
from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowRun, WorkflowType
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowNotFoundError
|
||||
from services.errors.app import (
|
||||
IsDraftWorkflowError,
|
||||
WorkflowNotFoundError,
|
||||
)
|
||||
from services.errors.app import (
|
||||
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
|
||||
)
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.workflow_app_service import WorkflowAppService
|
||||
|
||||
@ -582,6 +592,32 @@ class TestWorkflowRunApi:
|
||||
with pytest.raises(InvokeRateLimitHttpError):
|
||||
handler(api, session=sqlite_session, app_model=app_model, end_user=end_user)
|
||||
|
||||
def test_trigger_workflow_returns_stable_unavailable_error(
|
||||
self,
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
AppGenerateService,
|
||||
"generate",
|
||||
Mock(side_effect=TriggerWorkflowServiceModeUnavailableServiceError()),
|
||||
)
|
||||
api = WorkflowRunApi()
|
||||
handler = unwrap(api.post)
|
||||
|
||||
with app.test_request_context("/workflows/run", method="POST", json={"inputs": {}}):
|
||||
with pytest.raises(TriggerWorkflowServiceModeUnavailableError) as exc_info:
|
||||
handler(
|
||||
api,
|
||||
session=sqlite_session,
|
||||
app_model=_make_app_model(),
|
||||
end_user=_make_end_user(),
|
||||
)
|
||||
|
||||
assert exc_info.value.code == 403
|
||||
assert exc_info.value.error_code == "trigger_workflow_service_mode_unavailable"
|
||||
|
||||
def test_sandbox_billing_does_not_gate_default_workflow_run(
|
||||
self,
|
||||
app: Flask,
|
||||
@ -614,6 +650,33 @@ class TestWorkflowRunApi:
|
||||
|
||||
|
||||
class TestWorkflowRunByIdApi:
|
||||
def test_trigger_workflow_version_returns_stable_unavailable_error(
|
||||
self,
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
AppGenerateService,
|
||||
"generate",
|
||||
Mock(side_effect=TriggerWorkflowServiceModeUnavailableServiceError()),
|
||||
)
|
||||
api = WorkflowRunByIdApi()
|
||||
handler = unwrap(api.post)
|
||||
|
||||
with app.test_request_context("/workflows/w1/run", method="POST", json={"inputs": {}}):
|
||||
with pytest.raises(TriggerWorkflowServiceModeUnavailableError) as exc_info:
|
||||
handler(
|
||||
api,
|
||||
session=sqlite_session,
|
||||
app_model=_make_app_model(),
|
||||
end_user=_make_end_user(),
|
||||
workflow_id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
assert exc_info.value.code == 403
|
||||
assert exc_info.value.error_code == "trigger_workflow_service_mode_unavailable"
|
||||
|
||||
def test_rejects_sandbox_plan_with_upgrade_error(
|
||||
self,
|
||||
app: Flask,
|
||||
|
||||
@ -11,11 +11,15 @@ from controllers.web.error import (
|
||||
NotWorkflowAppError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
TriggerWorkflowServiceModeUnavailableError,
|
||||
)
|
||||
from controllers.web.workflow import WorkflowRunApi, WorkflowTaskStopApi
|
||||
from core.errors.error import ProviderTokenNotInitError, QuotaExceededError
|
||||
from models.enums import EndUserType
|
||||
from models.model import App, AppMode, EndUser
|
||||
from services.errors.app import (
|
||||
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
|
||||
)
|
||||
|
||||
|
||||
def _workflow_app() -> App:
|
||||
@ -68,6 +72,26 @@ class TestWorkflowRunApi:
|
||||
with pytest.raises(ProviderNotInitializeError):
|
||||
WorkflowRunApi().post(_workflow_app(), _end_user())
|
||||
|
||||
@patch(
|
||||
"controllers.web.workflow.AppGenerateService.generate",
|
||||
side_effect=TriggerWorkflowServiceModeUnavailableServiceError(),
|
||||
)
|
||||
@patch("controllers.web.workflow.web_ns")
|
||||
def test_trigger_workflow_returns_stable_unavailable_error(
|
||||
self,
|
||||
mock_ns: MagicMock,
|
||||
mock_gen: MagicMock,
|
||||
app: Flask,
|
||||
) -> None:
|
||||
mock_ns.payload = {"inputs": {}}
|
||||
|
||||
with app.test_request_context("/workflows/run", method="POST"):
|
||||
with pytest.raises(TriggerWorkflowServiceModeUnavailableError) as exc_info:
|
||||
WorkflowRunApi().post(_workflow_app(), _end_user())
|
||||
|
||||
assert exc_info.value.code == 403
|
||||
assert exc_info.value.error_code == "trigger_workflow_service_mode_unavailable"
|
||||
|
||||
@patch(
|
||||
"controllers.web.workflow.AppGenerateService.generate",
|
||||
side_effect=QuotaExceededError(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -21,6 +21,7 @@ from core.app.apps.agent_app.app_generator import (
|
||||
AgentAppGenerator,
|
||||
AgentAppGeneratorError,
|
||||
)
|
||||
from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from core.app.entities.queue_entities import QueueAnnotationReplyEvent
|
||||
@ -427,6 +428,23 @@ class TestGenerateWorker:
|
||||
self._call(generator, mocker, queue_manager)
|
||||
assert queue_manager.publish_error.called
|
||||
|
||||
def test_session_configuration_change_is_published_without_unknown_error_log(
|
||||
self,
|
||||
generator: AgentAppGenerator,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
error = AgentSessionSnapshotIncompatibleError()
|
||||
self._wire(generator, mocker, run_side_effect=error)
|
||||
queue_manager = mocker.MagicMock()
|
||||
info_log = mocker.patch(f"{MODULE}.logger.info")
|
||||
exception_log = mocker.patch(f"{MODULE}.logger.exception")
|
||||
|
||||
self._call(generator, mocker, queue_manager)
|
||||
|
||||
queue_manager.publish_error.assert_called_once_with(error, module.PublishFrom.APPLICATION_MANAGER)
|
||||
info_log.assert_called_once()
|
||||
exception_log.assert_not_called()
|
||||
|
||||
|
||||
class TestResumeAfterFormSubmission:
|
||||
"""ENG-638: a resume turn re-sends the paused turn's original query so the
|
||||
|
||||
@ -12,7 +12,8 @@ from typing import Any, override
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot
|
||||
from agenton.layers import LifecycleState
|
||||
from dify_agent.layers.ask_human import AskHumanToolResult
|
||||
from dify_agent.protocol import (
|
||||
AgentRunUsage,
|
||||
@ -52,7 +53,8 @@ from clients.agent_backend import (
|
||||
)
|
||||
from core.app.apps.agent_app import app_runner as app_runner_module
|
||||
from core.app.apps.agent_app.app_runner import AgentAppRunner
|
||||
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder
|
||||
from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError
|
||||
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeBuildContext, AgentAppRuntimeRequestBuilder
|
||||
from core.app.apps.agent_app.session_store import AgentAppSessionScope, StoredAgentAppSession
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom
|
||||
@ -628,6 +630,34 @@ def _dify_ctx() -> DifyRunContext:
|
||||
)
|
||||
|
||||
|
||||
def _compatible_session_snapshot() -> CompositorSessionSnapshot:
|
||||
request = (
|
||||
AgentAppRuntimeRequestBuilder(
|
||||
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
.build(
|
||||
AgentAppRuntimeBuildContext(
|
||||
dify_context=_dify_ctx(),
|
||||
agent_id="agent-1",
|
||||
agent_config_snapshot_id="snap-1",
|
||||
agent_soul=_soul(),
|
||||
conversation_id="conv-1",
|
||||
user_query="hello",
|
||||
idempotency_key="msg-1",
|
||||
binding_id="binding-1",
|
||||
backend_binding_ref="backend-binding-1",
|
||||
)
|
||||
)
|
||||
.request
|
||||
)
|
||||
return CompositorSessionSnapshot(
|
||||
layers=[
|
||||
LayerSessionSnapshot(name=layer.name, lifecycle_state=LifecycleState.SUSPENDED, runtime_state={})
|
||||
for layer in request.composition.layers
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _runner(
|
||||
client: FakeAgentBackendRunClient,
|
||||
store: _FakeSessionStore,
|
||||
@ -1314,7 +1344,7 @@ def test_repeated_tool_calls_with_placeholder_call_id_and_reused_index_create_di
|
||||
|
||||
|
||||
def test_prior_session_snapshot_is_threaded_into_request() -> None:
|
||||
prior = CompositorSessionSnapshot(layers=[])
|
||||
prior = _compatible_session_snapshot()
|
||||
client = FakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore(loaded=prior)
|
||||
qm = _FakeQueueManager()
|
||||
@ -1325,8 +1355,23 @@ def test_prior_session_snapshot_is_threaded_into_request() -> None:
|
||||
assert client.request.session_snapshot is prior
|
||||
|
||||
|
||||
def test_incompatible_session_snapshot_is_rejected_before_backend_invocation() -> None:
|
||||
compatible = _compatible_session_snapshot()
|
||||
stale = CompositorSessionSnapshot(
|
||||
layers=[layer for layer in compatible.layers if layer.name != "agent_soul_prompt"]
|
||||
)
|
||||
client = FakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore(loaded=stale)
|
||||
|
||||
with pytest.raises(AgentSessionSnapshotIncompatibleError, match="Start a new conversation"):
|
||||
_run(_runner(client, store), _FakeQueueManager())
|
||||
|
||||
assert client.request is None
|
||||
assert store.saved == []
|
||||
|
||||
|
||||
def test_debug_session_scope_can_reuse_conversation_across_config_snapshots() -> None:
|
||||
prior = CompositorSessionSnapshot(layers=[])
|
||||
prior = _compatible_session_snapshot()
|
||||
client = FakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore(loaded=prior)
|
||||
qm = _FakeQueueManager()
|
||||
@ -1599,7 +1644,7 @@ def test_ask_human_pauses_turn_creates_form_and_persists_correlation() -> None:
|
||||
def test_submitted_form_resumes_turn_with_deferred_tool_results(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# ENG-638: a turn that runs while a pending form is answered threads the
|
||||
# human's reply into the request as deferred_tool_results.
|
||||
snapshot = CompositorSessionSnapshot(layers=[])
|
||||
snapshot = _compatible_session_snapshot()
|
||||
stored = StoredAgentAppSession(
|
||||
scope=AgentAppSessionScope(
|
||||
tenant_id="tenant-1",
|
||||
|
||||
@ -6,6 +6,8 @@ from __future__ import annotations
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot
|
||||
from agenton.layers import LifecycleState
|
||||
from dify_agent.layers.config import DifyConfigSkillConfig
|
||||
from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig
|
||||
from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolsLayerConfig
|
||||
@ -20,6 +22,7 @@ from clients.agent_backend import (
|
||||
AgentBackendRunRequestBuilder,
|
||||
)
|
||||
from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID
|
||||
from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError
|
||||
from core.app.apps.agent_app.runtime_request_builder import (
|
||||
AgentAppRuntimeBuildContext,
|
||||
AgentAppRuntimeRequestBuilder,
|
||||
@ -172,6 +175,7 @@ def _ctx(
|
||||
*,
|
||||
query: str = "hello",
|
||||
agent_config_version_kind: str = "snapshot",
|
||||
session_snapshot: CompositorSessionSnapshot | None = None,
|
||||
) -> AgentAppRuntimeBuildContext:
|
||||
dify_context = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
@ -191,6 +195,7 @@ def _ctx(
|
||||
binding_id="binding-1",
|
||||
backend_binding_ref="binding-ref-1",
|
||||
agent_config_version_kind=agent_config_version_kind, # type: ignore[arg-type]
|
||||
session_snapshot=session_snapshot,
|
||||
)
|
||||
|
||||
|
||||
@ -207,6 +212,15 @@ def _soul_with_model() -> AgentSoulConfig:
|
||||
)
|
||||
|
||||
|
||||
def _snapshot_for_layer_names(layer_names: list[str]) -> CompositorSessionSnapshot:
|
||||
return CompositorSessionSnapshot(
|
||||
layers=[
|
||||
LayerSessionSnapshot(name=name, lifecycle_state=LifecycleState.SUSPENDED, runtime_state={})
|
||||
for name in layer_names
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TestAgentAppRuntimeRequestBuilder:
|
||||
def test_build_maps_soul_to_run_request(self, model_context_window_calls: list[tuple[object, str, str]]):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
@ -245,6 +259,57 @@ class TestAgentAppRuntimeRequestBuilder:
|
||||
assert "credentials" not in result.redacted_request["composition"]["layers"][-1]["config"]
|
||||
assert result.metadata["conversation_id"] == "conv-1"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("previous_prompt", "current_prompt"),
|
||||
[("", "You are Iris."), ("You are Iris.", "")],
|
||||
)
|
||||
def test_build_rejects_session_snapshot_after_layer_topology_changes(
|
||||
self,
|
||||
previous_prompt: str,
|
||||
current_prompt: str,
|
||||
) -> None:
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
previous_soul = _soul_with_model()
|
||||
previous_soul.prompt.system_prompt = previous_prompt
|
||||
previous_request = builder.build(_ctx(previous_soul, agent_config_version_kind="draft")).request
|
||||
snapshot = _snapshot_for_layer_names([layer.name for layer in previous_request.composition.layers])
|
||||
current_soul = _soul_with_model()
|
||||
current_soul.prompt.system_prompt = current_prompt
|
||||
|
||||
with pytest.raises(AgentSessionSnapshotIncompatibleError) as exc_info:
|
||||
builder.build(
|
||||
_ctx(
|
||||
current_soul,
|
||||
agent_config_version_kind="draft",
|
||||
session_snapshot=snapshot,
|
||||
)
|
||||
)
|
||||
|
||||
assert exc_info.value.error_code == "agent_session_configuration_changed"
|
||||
assert exc_info.value.status_code == 409
|
||||
assert "Start a new conversation" in str(exc_info.value)
|
||||
|
||||
def test_build_reuses_session_snapshot_when_config_changes_without_changing_layers(self) -> None:
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
previous_request = builder.build(_ctx(_soul_with_model(), agent_config_version_kind="draft")).request
|
||||
snapshot = _snapshot_for_layer_names([layer.name for layer in previous_request.composition.layers])
|
||||
current_soul = _soul_with_model()
|
||||
current_soul.prompt.system_prompt = "You are Ada."
|
||||
|
||||
result = builder.build(
|
||||
_ctx(
|
||||
current_soul,
|
||||
agent_config_version_kind="draft",
|
||||
session_snapshot=snapshot,
|
||||
)
|
||||
)
|
||||
|
||||
assert result.request.session_snapshot is snapshot
|
||||
|
||||
def test_build_wraps_agent_soul_prompt_for_build_draft(self):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
|
||||
@ -4,12 +4,16 @@ from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.app.apps.base_app_runner import AppRunner
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from graphon.file import FileTransferMethod, FileType
|
||||
from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import MessageFile
|
||||
from models.tools import ToolFile
|
||||
|
||||
|
||||
class TestBaseAppRunnerMultimodal:
|
||||
@ -38,18 +42,18 @@ class TestBaseAppRunnerMultimodal:
|
||||
return manager
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tool_file(self):
|
||||
"""Create a mock tool file."""
|
||||
tool_file = MagicMock()
|
||||
tool_file.id = str(uuid4())
|
||||
return tool_file
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message_file(self):
|
||||
"""Create a mock message file."""
|
||||
message_file = MagicMock()
|
||||
message_file.id = str(uuid4())
|
||||
return message_file
|
||||
def tool_file(self, mock_user_id: str, mock_tenant_id: str) -> ToolFile:
|
||||
"""Create a real transient tool-file model returned by the external file manager."""
|
||||
return ToolFile(
|
||||
user_id=mock_user_id,
|
||||
tenant_id=mock_tenant_id,
|
||||
conversation_id=None,
|
||||
file_key="generated/image.png",
|
||||
mimetype="image/png",
|
||||
original_url="http://example.com/image.png",
|
||||
name="image.png",
|
||||
size=68,
|
||||
)
|
||||
|
||||
def test_handle_multimodal_image_content_with_url(
|
||||
self,
|
||||
@ -57,8 +61,8 @@ class TestBaseAppRunnerMultimodal:
|
||||
mock_tenant_id,
|
||||
mock_message_id,
|
||||
mock_queue_manager,
|
||||
mock_tool_file,
|
||||
mock_message_file,
|
||||
tool_file,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
"""Test handling image from URL."""
|
||||
# Arrange
|
||||
@ -72,48 +76,33 @@ class TestBaseAppRunnerMultimodal:
|
||||
with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class:
|
||||
# Setup mock tool file manager
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.create_file_by_url.return_value = mock_tool_file
|
||||
mock_mgr.create_file_by_url.return_value = tool_file
|
||||
mock_mgr_class.return_value = mock_mgr
|
||||
|
||||
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
|
||||
# Setup mock message file
|
||||
mock_msg_file_class.return_value = mock_message_file
|
||||
message_file_id = AppRunner()._handle_multimodal_image_content(
|
||||
session=sqlite_session,
|
||||
content=content,
|
||||
message_id=mock_message_id,
|
||||
user_id=mock_user_id,
|
||||
tenant_id=mock_tenant_id,
|
||||
queue_manager=mock_queue_manager,
|
||||
)
|
||||
|
||||
file_session = MagicMock()
|
||||
# Act
|
||||
runner = MagicMock()
|
||||
method = AppRunner._handle_multimodal_image_content
|
||||
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
|
||||
|
||||
message_file_id = runner._handle_multimodal_image_content(
|
||||
session=file_session,
|
||||
content=content,
|
||||
message_id=mock_message_id,
|
||||
user_id=mock_user_id,
|
||||
tenant_id=mock_tenant_id,
|
||||
queue_manager=mock_queue_manager,
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_mgr.create_file_by_url.assert_called_once_with(
|
||||
user_id=mock_user_id,
|
||||
tenant_id=mock_tenant_id,
|
||||
file_url=image_url,
|
||||
conversation_id=None,
|
||||
)
|
||||
|
||||
mock_msg_file_class.assert_called_once()
|
||||
call_kwargs = mock_msg_file_class.call_args[1]
|
||||
assert call_kwargs["message_id"] == mock_message_id
|
||||
assert call_kwargs["type"] == FileType.IMAGE
|
||||
assert call_kwargs["transfer_method"] == FileTransferMethod.TOOL_FILE
|
||||
assert call_kwargs["belongs_to"] == "assistant"
|
||||
assert call_kwargs["created_by"] == mock_user_id
|
||||
|
||||
file_session.add.assert_called_once_with(mock_message_file)
|
||||
file_session.flush.assert_called_once()
|
||||
assert message_file_id == mock_message_file.id
|
||||
mock_queue_manager.publish.assert_not_called()
|
||||
mock_mgr.create_file_by_url.assert_called_once_with(
|
||||
user_id=mock_user_id,
|
||||
tenant_id=mock_tenant_id,
|
||||
file_url=image_url,
|
||||
conversation_id=None,
|
||||
)
|
||||
message_file = sqlite_session.get(MessageFile, message_file_id)
|
||||
assert message_file is not None
|
||||
assert message_file.message_id == mock_message_id
|
||||
assert message_file.type == FileType.IMAGE
|
||||
assert message_file.transfer_method == FileTransferMethod.TOOL_FILE
|
||||
assert message_file.belongs_to == "assistant"
|
||||
assert message_file.created_by == mock_user_id
|
||||
assert message_file.upload_file_id == tool_file.id
|
||||
mock_queue_manager.publish.assert_not_called()
|
||||
|
||||
def test_handle_multimodal_image_content_with_base64(
|
||||
self,
|
||||
@ -121,8 +110,8 @@ class TestBaseAppRunnerMultimodal:
|
||||
mock_tenant_id,
|
||||
mock_message_id,
|
||||
mock_queue_manager,
|
||||
mock_tool_file,
|
||||
mock_message_file,
|
||||
tool_file,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
"""Test handling image from base64 data."""
|
||||
# Arrange
|
||||
@ -141,41 +130,29 @@ class TestBaseAppRunnerMultimodal:
|
||||
with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class:
|
||||
# Setup mock tool file manager
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.create_file_by_raw.return_value = mock_tool_file
|
||||
mock_mgr.create_file_by_raw.return_value = tool_file
|
||||
mock_mgr_class.return_value = mock_mgr
|
||||
|
||||
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
|
||||
mock_msg_file_class.return_value = mock_message_file
|
||||
message_file_id = AppRunner()._handle_multimodal_image_content(
|
||||
session=sqlite_session,
|
||||
content=content,
|
||||
message_id=mock_message_id,
|
||||
user_id=mock_user_id,
|
||||
tenant_id=mock_tenant_id,
|
||||
queue_manager=mock_queue_manager,
|
||||
)
|
||||
|
||||
file_session = MagicMock()
|
||||
runner = MagicMock()
|
||||
method = AppRunner._handle_multimodal_image_content
|
||||
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
|
||||
|
||||
message_file_id = runner._handle_multimodal_image_content(
|
||||
session=file_session,
|
||||
content=content,
|
||||
message_id=mock_message_id,
|
||||
user_id=mock_user_id,
|
||||
tenant_id=mock_tenant_id,
|
||||
queue_manager=mock_queue_manager,
|
||||
)
|
||||
|
||||
mock_mgr.create_file_by_raw.assert_called_once()
|
||||
call_kwargs = mock_mgr.create_file_by_raw.call_args[1]
|
||||
assert call_kwargs["user_id"] == mock_user_id
|
||||
assert call_kwargs["tenant_id"] == mock_tenant_id
|
||||
assert call_kwargs["conversation_id"] is None
|
||||
assert "file_binary" in call_kwargs
|
||||
assert call_kwargs["mimetype"] == "image/png"
|
||||
assert call_kwargs["filename"].startswith("generated_image")
|
||||
assert call_kwargs["filename"].endswith(".png")
|
||||
|
||||
mock_msg_file_class.assert_called_once()
|
||||
file_session.add.assert_called_once()
|
||||
file_session.flush.assert_called_once()
|
||||
assert message_file_id == mock_message_file.id
|
||||
mock_queue_manager.publish.assert_not_called()
|
||||
mock_mgr.create_file_by_raw.assert_called_once()
|
||||
call_kwargs = mock_mgr.create_file_by_raw.call_args[1]
|
||||
assert call_kwargs["user_id"] == mock_user_id
|
||||
assert call_kwargs["tenant_id"] == mock_tenant_id
|
||||
assert call_kwargs["conversation_id"] is None
|
||||
assert "file_binary" in call_kwargs
|
||||
assert call_kwargs["mimetype"] == "image/png"
|
||||
assert call_kwargs["filename"].startswith("generated_image")
|
||||
assert call_kwargs["filename"].endswith(".png")
|
||||
assert sqlite_session.get(MessageFile, message_file_id) is not None
|
||||
mock_queue_manager.publish.assert_not_called()
|
||||
|
||||
def test_handle_multimodal_image_content_with_base64_data_uri(
|
||||
self,
|
||||
@ -183,8 +160,8 @@ class TestBaseAppRunnerMultimodal:
|
||||
mock_tenant_id,
|
||||
mock_message_id,
|
||||
mock_queue_manager,
|
||||
mock_tool_file,
|
||||
mock_message_file,
|
||||
tool_file,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
"""Test handling image from base64 data with URI prefix."""
|
||||
# Arrange
|
||||
@ -201,29 +178,22 @@ class TestBaseAppRunnerMultimodal:
|
||||
with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class:
|
||||
# Setup mock tool file manager
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.create_file_by_raw.return_value = mock_tool_file
|
||||
mock_mgr.create_file_by_raw.return_value = tool_file
|
||||
mock_mgr_class.return_value = mock_mgr
|
||||
|
||||
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
|
||||
mock_msg_file_class.return_value = mock_message_file
|
||||
message_file_id = AppRunner()._handle_multimodal_image_content(
|
||||
session=sqlite_session,
|
||||
content=content,
|
||||
message_id=mock_message_id,
|
||||
user_id=mock_user_id,
|
||||
tenant_id=mock_tenant_id,
|
||||
queue_manager=mock_queue_manager,
|
||||
)
|
||||
|
||||
file_session = MagicMock()
|
||||
runner = MagicMock()
|
||||
method = AppRunner._handle_multimodal_image_content
|
||||
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
|
||||
|
||||
runner._handle_multimodal_image_content(
|
||||
session=file_session,
|
||||
content=content,
|
||||
message_id=mock_message_id,
|
||||
user_id=mock_user_id,
|
||||
tenant_id=mock_tenant_id,
|
||||
queue_manager=mock_queue_manager,
|
||||
)
|
||||
|
||||
mock_mgr.create_file_by_raw.assert_called_once()
|
||||
call_kwargs = mock_mgr.create_file_by_raw.call_args[1]
|
||||
assert "file_binary" in call_kwargs
|
||||
mock_mgr.create_file_by_raw.assert_called_once()
|
||||
call_kwargs = mock_mgr.create_file_by_raw.call_args[1]
|
||||
assert "file_binary" in call_kwargs
|
||||
assert sqlite_session.get(MessageFile, message_file_id) is not None
|
||||
|
||||
def test_handle_multimodal_image_content_without_url_or_base64(
|
||||
self,
|
||||
@ -231,6 +201,7 @@ class TestBaseAppRunnerMultimodal:
|
||||
mock_tenant_id,
|
||||
mock_message_id,
|
||||
mock_queue_manager,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
"""Test handling image content without URL or base64 data."""
|
||||
# Arrange
|
||||
@ -242,24 +213,19 @@ class TestBaseAppRunnerMultimodal:
|
||||
)
|
||||
|
||||
with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class:
|
||||
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
|
||||
file_session = MagicMock()
|
||||
runner = MagicMock()
|
||||
method = AppRunner._handle_multimodal_image_content
|
||||
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
|
||||
result = AppRunner()._handle_multimodal_image_content(
|
||||
session=sqlite_session,
|
||||
content=content,
|
||||
message_id=mock_message_id,
|
||||
user_id=mock_user_id,
|
||||
tenant_id=mock_tenant_id,
|
||||
queue_manager=mock_queue_manager,
|
||||
)
|
||||
|
||||
runner._handle_multimodal_image_content(
|
||||
session=file_session,
|
||||
content=content,
|
||||
message_id=mock_message_id,
|
||||
user_id=mock_user_id,
|
||||
tenant_id=mock_tenant_id,
|
||||
queue_manager=mock_queue_manager,
|
||||
)
|
||||
|
||||
mock_mgr_class.assert_not_called()
|
||||
mock_msg_file_class.assert_not_called()
|
||||
mock_queue_manager.publish.assert_not_called()
|
||||
assert result is None
|
||||
assert sqlite_session.scalar(select(func.count()).select_from(MessageFile)) == 0
|
||||
mock_mgr_class.assert_not_called()
|
||||
mock_queue_manager.publish.assert_not_called()
|
||||
|
||||
def test_handle_multimodal_image_content_with_error(
|
||||
self,
|
||||
@ -267,6 +233,7 @@ class TestBaseAppRunnerMultimodal:
|
||||
mock_tenant_id,
|
||||
mock_message_id,
|
||||
mock_queue_manager,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
"""Test handling image content when an error occurs."""
|
||||
# Arrange
|
||||
@ -282,23 +249,18 @@ class TestBaseAppRunnerMultimodal:
|
||||
mock_mgr.create_file_by_url.side_effect = Exception("Network error")
|
||||
mock_mgr_class.return_value = mock_mgr
|
||||
|
||||
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
|
||||
file_session = MagicMock()
|
||||
runner = MagicMock()
|
||||
method = AppRunner._handle_multimodal_image_content
|
||||
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
|
||||
result = AppRunner()._handle_multimodal_image_content(
|
||||
session=sqlite_session,
|
||||
content=content,
|
||||
message_id=mock_message_id,
|
||||
user_id=mock_user_id,
|
||||
tenant_id=mock_tenant_id,
|
||||
queue_manager=mock_queue_manager,
|
||||
)
|
||||
|
||||
runner._handle_multimodal_image_content(
|
||||
session=file_session,
|
||||
content=content,
|
||||
message_id=mock_message_id,
|
||||
user_id=mock_user_id,
|
||||
tenant_id=mock_tenant_id,
|
||||
queue_manager=mock_queue_manager,
|
||||
)
|
||||
|
||||
mock_msg_file_class.assert_not_called()
|
||||
mock_queue_manager.publish.assert_not_called()
|
||||
assert result is None
|
||||
assert sqlite_session.scalar(select(func.count()).select_from(MessageFile)) == 0
|
||||
mock_queue_manager.publish.assert_not_called()
|
||||
|
||||
def test_handle_multimodal_image_content_debugger_mode(
|
||||
self,
|
||||
@ -306,8 +268,8 @@ class TestBaseAppRunnerMultimodal:
|
||||
mock_tenant_id,
|
||||
mock_message_id,
|
||||
mock_queue_manager,
|
||||
mock_tool_file,
|
||||
mock_message_file,
|
||||
tool_file,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
"""Test that debugger mode sets correct created_by_role."""
|
||||
# Arrange
|
||||
@ -321,28 +283,21 @@ class TestBaseAppRunnerMultimodal:
|
||||
|
||||
with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class:
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.create_file_by_url.return_value = mock_tool_file
|
||||
mock_mgr.create_file_by_url.return_value = tool_file
|
||||
mock_mgr_class.return_value = mock_mgr
|
||||
|
||||
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
|
||||
mock_msg_file_class.return_value = mock_message_file
|
||||
message_file_id = AppRunner()._handle_multimodal_image_content(
|
||||
session=sqlite_session,
|
||||
content=content,
|
||||
message_id=mock_message_id,
|
||||
user_id=mock_user_id,
|
||||
tenant_id=mock_tenant_id,
|
||||
queue_manager=mock_queue_manager,
|
||||
)
|
||||
|
||||
file_session = MagicMock()
|
||||
runner = MagicMock()
|
||||
method = AppRunner._handle_multimodal_image_content
|
||||
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
|
||||
|
||||
runner._handle_multimodal_image_content(
|
||||
session=file_session,
|
||||
content=content,
|
||||
message_id=mock_message_id,
|
||||
user_id=mock_user_id,
|
||||
tenant_id=mock_tenant_id,
|
||||
queue_manager=mock_queue_manager,
|
||||
)
|
||||
|
||||
call_kwargs = mock_msg_file_class.call_args[1]
|
||||
assert call_kwargs["created_by_role"] == CreatorUserRole.ACCOUNT
|
||||
message_file = sqlite_session.get(MessageFile, message_file_id)
|
||||
assert message_file is not None
|
||||
assert message_file.created_by_role == CreatorUserRole.ACCOUNT
|
||||
|
||||
def test_handle_multimodal_image_content_service_api_mode(
|
||||
self,
|
||||
@ -350,8 +305,8 @@ class TestBaseAppRunnerMultimodal:
|
||||
mock_tenant_id,
|
||||
mock_message_id,
|
||||
mock_queue_manager,
|
||||
mock_tool_file,
|
||||
mock_message_file,
|
||||
tool_file,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
"""Test that service API mode sets correct created_by_role."""
|
||||
# Arrange
|
||||
@ -365,25 +320,18 @@ class TestBaseAppRunnerMultimodal:
|
||||
|
||||
with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class:
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.create_file_by_url.return_value = mock_tool_file
|
||||
mock_mgr.create_file_by_url.return_value = tool_file
|
||||
mock_mgr_class.return_value = mock_mgr
|
||||
|
||||
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
|
||||
mock_msg_file_class.return_value = mock_message_file
|
||||
message_file_id = AppRunner()._handle_multimodal_image_content(
|
||||
session=sqlite_session,
|
||||
content=content,
|
||||
message_id=mock_message_id,
|
||||
user_id=mock_user_id,
|
||||
tenant_id=mock_tenant_id,
|
||||
queue_manager=mock_queue_manager,
|
||||
)
|
||||
|
||||
file_session = MagicMock()
|
||||
runner = MagicMock()
|
||||
method = AppRunner._handle_multimodal_image_content
|
||||
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
|
||||
|
||||
runner._handle_multimodal_image_content(
|
||||
session=file_session,
|
||||
content=content,
|
||||
message_id=mock_message_id,
|
||||
user_id=mock_user_id,
|
||||
tenant_id=mock_tenant_id,
|
||||
queue_manager=mock_queue_manager,
|
||||
)
|
||||
|
||||
call_kwargs = mock_msg_file_class.call_args[1]
|
||||
assert call_kwargs["created_by_role"] == CreatorUserRole.END_USER
|
||||
message_file = sqlite_session.get(MessageFile, message_file_id)
|
||||
assert message_file is not None
|
||||
assert message_file.created_by_role == CreatorUserRole.END_USER
|
||||
|
||||
@ -7,6 +7,7 @@ from dify_agent.protocol import RunFailureType
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from clients.agent_backend.errors import AgentBackendRunFailedError
|
||||
from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError
|
||||
from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.entities.queue_entities import QueueErrorEvent
|
||||
@ -168,6 +169,17 @@ class TestBasedGenerateTaskPipeline:
|
||||
"message": "run limit reached (agent_run_id=run-1)",
|
||||
}
|
||||
|
||||
def test_stream_converter_preserves_agent_session_configuration_error(self):
|
||||
data = AppGenerateResponseConverter._error_to_stream_response(AgentSessionSnapshotIncompatibleError())
|
||||
|
||||
assert data == {
|
||||
"code": "agent_session_configuration_changed",
|
||||
"status": 409,
|
||||
"message": (
|
||||
"The Agent configuration changed after this conversation started. Start a new conversation to continue."
|
||||
),
|
||||
}
|
||||
|
||||
def test_handle_output_moderation_when_flagged(self, pipeline):
|
||||
handler = Mock()
|
||||
handler.moderation_completion.return_value = ("filtered", True)
|
||||
|
||||
@ -22,6 +22,7 @@ from core.mcp.server.streamable_http import (
|
||||
)
|
||||
from graphon.variables.input_entities import VariableEntity, VariableEntityType
|
||||
from models.model import App, AppMCPServer, AppMode, EndUser
|
||||
from services.errors.app import TriggerWorkflowServiceModeUnavailableError
|
||||
|
||||
|
||||
class TestHandleMCPRequest:
|
||||
@ -157,6 +158,29 @@ class TestHandleMCPRequest:
|
||||
# Verify AppGenerateService was called
|
||||
mock_app_generate.generate.assert_called_once()
|
||||
|
||||
@patch("core.mcp.server.streamable_http.AppGenerateService")
|
||||
def test_handle_call_tool_returns_trigger_workflow_business_error(self, mock_app_generate):
|
||||
mock_call_request = Mock(spec=types.CallToolRequest)
|
||||
mock_call_request.params = Mock()
|
||||
mock_call_request.params.arguments = {"query": "test question"}
|
||||
mock_call_request.id = 123
|
||||
self.mock_request.root = mock_call_request
|
||||
mock_app_generate.generate.side_effect = TriggerWorkflowServiceModeUnavailableError()
|
||||
|
||||
result = handle_mcp_request(
|
||||
Mock(),
|
||||
self.app,
|
||||
self.mock_request,
|
||||
self.user_input_form,
|
||||
self.mcp_server,
|
||||
self.end_user,
|
||||
123,
|
||||
)
|
||||
|
||||
assert isinstance(result, types.JSONRPCError)
|
||||
assert result.error.code == types.INVALID_REQUEST
|
||||
assert result.error.data == {"code": "trigger_workflow_service_mode_unavailable"}
|
||||
|
||||
@patch("core.mcp.server.streamable_http.AppGenerateService")
|
||||
def test_handle_call_tool_request_threads_protocol_version(self, mock_app_generate):
|
||||
"""The negotiated version reaches handle_call_tool through the dispatcher."""
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from opentelemetry.trace import StatusCode, get_current_span, get_tracer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.rag.rerank.rerank_type import RerankMode
|
||||
from core.rag.retrieval.dataset_retrieval import DatasetRetrieval
|
||||
@ -20,6 +21,7 @@ def _otel_enabled(config_overrides: Callable[..., None]) -> None:
|
||||
def test_knowledge_retrieval_creates_a_child_otel_span(
|
||||
memory_span_exporter,
|
||||
tracer_provider_with_memory_exporter,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
"""The retrieval entry point must be visible beneath its workflow node span."""
|
||||
request = KnowledgeRetrievalRequest(
|
||||
@ -38,7 +40,7 @@ def test_knowledge_retrieval_creates_a_child_otel_span(
|
||||
patch.object(retrieval, "_get_available_datasets", return_value=[]),
|
||||
get_tracer(__name__).start_as_current_span("knowledge-retrieval-node") as node_span,
|
||||
):
|
||||
assert retrieval.knowledge_retrieval(MagicMock(), request) == []
|
||||
assert retrieval.knowledge_retrieval(sqlite_session, request) == []
|
||||
|
||||
retrieval_span = next(
|
||||
span
|
||||
@ -101,7 +103,6 @@ def test_retriever_thread_exception_sets_error_span_and_is_collected(
|
||||
expected_error = RuntimeError("retrieval failed")
|
||||
|
||||
with (
|
||||
patch("core.rag.retrieval.dataset_retrieval.session_factory.create_session"),
|
||||
patch.object(retrieval, "_retriever", side_effect=expected_error),
|
||||
):
|
||||
retrieval._run_retriever_thread_safely(
|
||||
@ -139,7 +140,6 @@ def test_retriever_thread_exception_emits_skip_event_when_requested(
|
||||
dataset_id = str(uuid4())
|
||||
|
||||
with (
|
||||
patch("core.rag.retrieval.dataset_retrieval.session_factory.create_session"),
|
||||
patch.object(retrieval, "_retriever", side_effect=expected_error),
|
||||
get_tracer(__name__).start_as_current_span("dataset-retrieval-parent") as parent_span,
|
||||
):
|
||||
|
||||
@ -32,6 +32,12 @@ from services.account_activation_adapters import (
|
||||
RegisterServiceInvitationTokenStore,
|
||||
)
|
||||
from services.account_avatar_file_gateway import SQLAlchemyAccountAvatarFileGateway
|
||||
from services.account_email_registration_adapters import (
|
||||
AccountServiceRegistrationGateway,
|
||||
BillingAccountRegistrationPolicyGateway,
|
||||
RedisEmailRegistrationSecurityGateway,
|
||||
TokenManagerEmailRegistrationTokenGateway,
|
||||
)
|
||||
from services.app_site_service import AppSiteService
|
||||
from services.auth.data_source_api_key_auth_service import DataSourceApiKeyAuthService
|
||||
from services.billing_portal_service import BillingPortalService
|
||||
@ -367,8 +373,17 @@ def test_build_application_services_wires_account_profile_repository(
|
||||
assert services.accounts.initialization._accounts is accounts
|
||||
assert not services.accounts.initialization._invitation_required
|
||||
assert services.accounts.change_email._accounts is accounts
|
||||
email_registration = services.accounts.email_registration
|
||||
assert email_registration._accounts is accounts
|
||||
assert isinstance(email_registration._tokens, TokenManagerEmailRegistrationTokenGateway)
|
||||
assert isinstance(email_registration._security, RedisEmailRegistrationSecurityGateway)
|
||||
assert isinstance(email_registration._account_policy, BillingAccountRegistrationPolicyGateway)
|
||||
assert isinstance(email_registration._registration, AccountServiceRegistrationGateway)
|
||||
assert email_registration._registration._session_factory is sqlite_session_factory
|
||||
assert services.accounts.education._accounts is accounts
|
||||
assert services.accounts.deletion._accounts is accounts
|
||||
assert services.notifications._accounts is accounts
|
||||
assert services.step_by_step_tour._accounts is accounts
|
||||
assert services.accounts.deletion._memberships is services.workspace_queries._workspaces
|
||||
integrations = services.accounts.integrations._integrations
|
||||
assert isinstance(integrations, SQLAlchemyAccountIntegrationRepository)
|
||||
|
||||
@ -97,6 +97,20 @@ def test_account_repository_updates_password(
|
||||
assert persisted.password_salt == "new-salt"
|
||||
|
||||
|
||||
def test_account_repository_finds_email_with_lowercase_fallback(
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
_persist_account(sqlite_session)
|
||||
repository = SQLAlchemyAccountRepository(sqlite_session_factory)
|
||||
|
||||
account = repository.find_by_email("Account@Example.com")
|
||||
|
||||
assert account is not None
|
||||
assert account.id == "account-1"
|
||||
assert account.email == "account@example.com"
|
||||
|
||||
|
||||
def test_account_integration_repository_lists_integrations(
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
|
||||
@ -0,0 +1,171 @@
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.onboarding import AccountStepByStepTourState
|
||||
from repositories.step_by_step_tour_repository import (
|
||||
SQLAlchemyStepByStepTourStateRepository,
|
||||
_is_retryable_mysql_lock_error,
|
||||
)
|
||||
|
||||
|
||||
class _ErrnoOnlyError(Exception):
|
||||
def __init__(self, errno: int | str) -> None:
|
||||
super().__init__()
|
||||
self.errno = errno
|
||||
|
||||
|
||||
def test_mutate_creates_and_updates_state_in_repository_owned_transaction(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory)
|
||||
|
||||
saved = repository.mutate(
|
||||
"account-1",
|
||||
lambda state: replace(state, completed_task_ids=("home",)),
|
||||
)
|
||||
reloaded = repository.get("account-1")
|
||||
|
||||
assert saved.first_workspace_id is None
|
||||
assert saved.completed_task_ids == ("home",)
|
||||
assert saved.updated_at is not None
|
||||
assert reloaded == saved
|
||||
|
||||
|
||||
def test_initialize_creates_state_with_first_workspace_atomically(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory)
|
||||
|
||||
result = repository.initialize("account-1", "workspace-1")
|
||||
|
||||
assert result.first_workspace_id == "workspace-1"
|
||||
assert repository.get("account-1") == result
|
||||
|
||||
|
||||
def test_initialize_claims_empty_state_once_without_overwriting_winner(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory)
|
||||
with sqlite_session_factory() as session:
|
||||
session.add(AccountStepByStepTourState(account_id="account-1"))
|
||||
session.commit()
|
||||
|
||||
first = repository.initialize("account-1", "workspace-1")
|
||||
second = repository.initialize("account-1", "workspace-2")
|
||||
|
||||
assert first.first_workspace_id == "workspace-1"
|
||||
assert second.first_workspace_id == "workspace-1"
|
||||
|
||||
|
||||
def test_mutate_cannot_clear_or_overwrite_first_workspace(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory)
|
||||
repository.initialize("account-1", "workspace-1")
|
||||
|
||||
result = repository.mutate(
|
||||
"account-1",
|
||||
lambda state: replace(state, first_workspace_id="workspace-2", skipped=True),
|
||||
)
|
||||
|
||||
assert result.first_workspace_id == "workspace-1"
|
||||
assert result.skipped is True
|
||||
|
||||
|
||||
def test_sequential_mutations_replay_against_latest_state(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory)
|
||||
|
||||
repository.mutate("account-1", lambda state: replace(state, completed_task_ids=("home",)))
|
||||
result = repository.mutate(
|
||||
"account-1",
|
||||
lambda state: replace(state, completed_task_ids=(*state.completed_task_ids, "studio")),
|
||||
)
|
||||
|
||||
assert result.completed_task_ids == ("home", "studio")
|
||||
|
||||
|
||||
def test_mutate_replays_after_concurrent_create_conflict() -> None:
|
||||
concurrent_state = AccountStepByStepTourState(account_id="account-1")
|
||||
concurrent_state.completed_task_ids = ["home"]
|
||||
concurrent_state.updated_at = datetime(2026, 8, 13)
|
||||
session = MagicMock(spec=Session)
|
||||
session.execute.return_value.scalar_one_or_none.side_effect = [None, concurrent_state]
|
||||
session.flush.side_effect = IntegrityError("insert", {}, Exception("duplicate"))
|
||||
factory = cast(sessionmaker[Session], Mock(return_value=nullcontext(session)))
|
||||
repository = SQLAlchemyStepByStepTourStateRepository(factory)
|
||||
|
||||
result = repository.mutate(
|
||||
"account-1",
|
||||
lambda state: replace(state, completed_task_ids=(*state.completed_task_ids, "studio")),
|
||||
)
|
||||
|
||||
assert result.completed_task_ids == ("home", "studio")
|
||||
session.rollback.assert_called_once_with()
|
||||
initial_probe = session.execute.call_args_list[0].args[0]
|
||||
replay_statement = session.execute.call_args_list[1].args[0]
|
||||
assert initial_probe._for_update_arg is None
|
||||
assert replay_statement._for_update_arg is not None
|
||||
|
||||
|
||||
def test_mutate_retries_mysql_deadlock_with_fresh_session() -> None:
|
||||
concurrent_state = AccountStepByStepTourState(account_id="account-1")
|
||||
concurrent_state.completed_task_ids = ["home"]
|
||||
concurrent_state.updated_at = datetime(2026, 8, 13)
|
||||
|
||||
deadlocked_session = MagicMock(spec=Session)
|
||||
deadlocked_session.execute.return_value.scalar_one_or_none.return_value = None
|
||||
deadlocked_session.flush.side_effect = OperationalError(
|
||||
"INSERT",
|
||||
{},
|
||||
Exception(1213, "Deadlock found when trying to get lock"),
|
||||
)
|
||||
|
||||
retry_session = MagicMock(spec=Session)
|
||||
retry_session.execute.return_value.scalar_one_or_none.side_effect = [concurrent_state, concurrent_state]
|
||||
factory = Mock(side_effect=[nullcontext(deadlocked_session), nullcontext(retry_session)])
|
||||
repository = SQLAlchemyStepByStepTourStateRepository(cast(sessionmaker[Session], factory))
|
||||
|
||||
result = repository.mutate(
|
||||
"account-1",
|
||||
lambda state: replace(state, completed_task_ids=(*state.completed_task_ids, "studio")),
|
||||
)
|
||||
|
||||
assert result.completed_task_ids == ("home", "studio")
|
||||
assert factory.call_count == 2
|
||||
retry_lock_statement = retry_session.execute.call_args_list[1].args[0]
|
||||
assert retry_lock_statement._for_update_arg is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("orig", "expected"),
|
||||
[
|
||||
pytest.param(_ErrnoOnlyError(1205), True, id="errno-attribute"),
|
||||
pytest.param(Exception(1213, "deadlock"), True, id="integer-args-code"),
|
||||
pytest.param(Exception("1213", "deadlock"), True, id="string-args-code"),
|
||||
pytest.param(Exception(9999, "other error"), False, id="non-retryable-code"),
|
||||
pytest.param(Exception(True), False, id="boolean-is-not-an-error-code"),
|
||||
pytest.param(Exception(), False, id="missing-error-code"),
|
||||
],
|
||||
)
|
||||
def test_mysql_lock_error_detection_preserves_errno_and_args_coverage(
|
||||
orig: BaseException,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
exc = OperationalError("statement", {}, orig)
|
||||
|
||||
assert _is_retryable_mysql_lock_error(exc) is expected
|
||||
|
||||
|
||||
def test_get_returns_none_for_unknown_account(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
assert SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory).get("missing") is None
|
||||
@ -4,10 +4,14 @@ from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from graphon.enums import BuiltinNodeTypes
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
@ -191,7 +195,9 @@ def test_agent_package_rejects_null_file_id_for_available_assets(asset: dict) ->
|
||||
AgentPackage.model_validate(package)
|
||||
|
||||
|
||||
def test_import_warnings_cover_runtime_setup_removed_from_package(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_import_warnings_cover_runtime_setup_removed_from_package(
|
||||
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
|
||||
) -> None:
|
||||
soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"tools": {
|
||||
@ -211,7 +217,7 @@ def test_import_warnings_cover_runtime_setup_removed_from_package(monkeypatch: p
|
||||
)
|
||||
monkeypatch.setattr("services.agent.dsl_service.get_tenant_knowledge_dataset_rows", Mock(return_value={}))
|
||||
|
||||
_, warnings = AgentDslService(Mock())._resolve_package_soul(
|
||||
_, warnings = AgentDslService(unbound_session)._resolve_package_soul(
|
||||
tenant_id="tenant-1",
|
||||
package=make_portable_agent_package(_agent(), soul),
|
||||
package_path="agent_packages.agent_1",
|
||||
@ -231,23 +237,29 @@ def test_agent_package_rejects_unknown_schema_version() -> None:
|
||||
AgentPackage.model_validate(package)
|
||||
|
||||
|
||||
def test_export_agent_app_requires_backing_agent() -> None:
|
||||
session = Mock()
|
||||
session.scalar.return_value = None
|
||||
|
||||
def test_export_agent_app_requires_backing_agent(sqlite_session: Session) -> None:
|
||||
with pytest.raises(ValueError, match="no active backing Agent"):
|
||||
AgentDslService(session).export_agent_app(app=SimpleNamespace(tenant_id="tenant-1", id="app-1"))
|
||||
AgentDslService(sqlite_session).export_agent_app(app=SimpleNamespace(tenant_id="tenant-1", id="app-1"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_draft", [True, False])
|
||||
def test_export_agent_app_uses_draft_or_active_snapshot(use_draft: bool) -> None:
|
||||
def test_export_agent_app_uses_draft_or_active_snapshot(use_draft: bool, sqlite_session: Session) -> None:
|
||||
agent = _agent()
|
||||
agent.app_id = "app-1"
|
||||
agent.active_config_snapshot_id = "snapshot-1"
|
||||
draft = SimpleNamespace(config_snapshot_dict=AgentSoulConfig(config_note="draft").model_dump(mode="json"))
|
||||
session = Mock()
|
||||
session.scalar.side_effect = [agent, draft if use_draft else None]
|
||||
session.execute.return_value = []
|
||||
service = AgentDslService(session)
|
||||
sqlite_session.add(agent)
|
||||
if use_draft:
|
||||
sqlite_session.add(
|
||||
AgentConfigDraft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id=agent.id,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
draft_owner_key="",
|
||||
config_snapshot=AgentSoulConfig(config_note="draft"),
|
||||
)
|
||||
)
|
||||
sqlite_session.flush()
|
||||
service = AgentDslService(sqlite_session)
|
||||
require_snapshot = Mock(return_value=_snapshot(soul=AgentSoulConfig(config_note="snapshot")))
|
||||
service._require_snapshot = require_snapshot
|
||||
|
||||
@ -258,22 +270,26 @@ def test_export_agent_app_uses_draft_or_active_snapshot(use_draft: bool) -> None
|
||||
assert require_snapshot.call_count == (0 if use_draft else 1)
|
||||
|
||||
|
||||
def test_export_workflow_packages_deduplicates_shared_agent() -> None:
|
||||
def test_export_workflow_packages_deduplicates_shared_agent(sqlite_session: Session) -> None:
|
||||
graph = {"nodes": [_agent_node("node-1"), _agent_node("node-2")], "edges": []}
|
||||
bindings = [
|
||||
SimpleNamespace(
|
||||
WorkflowAgentNodeBinding(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_version="draft",
|
||||
node_id=node_id,
|
||||
agent_id="agent-1",
|
||||
current_snapshot_id="snapshot-1",
|
||||
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
|
||||
node_job_config_dict={"workflow_prompt": node_id},
|
||||
node_job_config={"workflow_prompt": node_id},
|
||||
created_by="account-1",
|
||||
)
|
||||
for node_id in ("node-1", "node-2")
|
||||
]
|
||||
session = Mock()
|
||||
session.scalars.return_value.all.return_value = bindings
|
||||
session.execute.return_value = []
|
||||
service = AgentDslService(session)
|
||||
sqlite_session.add_all(bindings)
|
||||
sqlite_session.flush()
|
||||
service = AgentDslService(sqlite_session)
|
||||
service._require_agent = Mock(return_value=_agent())
|
||||
service._require_snapshot = Mock(return_value=_snapshot())
|
||||
|
||||
@ -292,12 +308,9 @@ def test_export_workflow_packages_deduplicates_shared_agent() -> None:
|
||||
assert service._require_agent.call_count == 2
|
||||
|
||||
|
||||
def test_export_workflow_packages_rejects_incomplete_binding() -> None:
|
||||
session = Mock()
|
||||
session.scalars.return_value.all.return_value = []
|
||||
|
||||
def test_export_workflow_packages_rejects_incomplete_binding(sqlite_session: Session) -> None:
|
||||
with pytest.raises(ValueError, match="no complete persisted binding"):
|
||||
AgentDslService(session).export_workflow_packages(
|
||||
AgentDslService(sqlite_session).export_workflow_packages(
|
||||
workflow=SimpleNamespace(tenant_id="tenant-1", id="workflow-1", version="draft"),
|
||||
graph={"nodes": [_agent_node("node-1")], "edges": []},
|
||||
)
|
||||
@ -328,9 +341,10 @@ def test_graph_without_package_bindings_removes_portable_fields() -> None:
|
||||
assert AGENT_NODE_JOB_DSL_KEY in graph["nodes"][0]["data"]
|
||||
|
||||
|
||||
def test_import_agent_app_package_creates_config_and_unpublished_draft(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session = Mock()
|
||||
service = AgentDslService(session)
|
||||
def test_import_agent_app_package_creates_config_and_unpublished_draft(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
service = AgentDslService(sqlite_session)
|
||||
soul = AgentSoulConfig(config_note="portable")
|
||||
warning = DslImportWarning(code="setup", path="agent.soul", message="setup required")
|
||||
service._resolve_package_soul = Mock(return_value=(soul, [warning]))
|
||||
@ -361,11 +375,10 @@ def test_import_agent_app_package_creates_config_and_unpublished_draft(monkeypat
|
||||
assert agent.active_config_is_published is False
|
||||
assert app.name == "Portable Agent"
|
||||
assert app.description == "description"
|
||||
assert session.add.call_count == 2
|
||||
assert session.flush.call_count == 2
|
||||
assert sqlite_session.scalar(select(AgentConfigDraft).where(AgentConfigDraft.agent_id == agent.id)) is not None
|
||||
|
||||
|
||||
def test_import_workflow_packages_materializes_every_package_binding_as_inline() -> None:
|
||||
def test_import_workflow_packages_materializes_every_package_binding_as_inline(sqlite_session: Session) -> None:
|
||||
package = make_portable_agent_package(_agent(), AgentSoulConfig())
|
||||
graph = {
|
||||
"nodes": [
|
||||
@ -388,14 +401,22 @@ def test_import_workflow_packages_materializes_every_package_binding_as_inline()
|
||||
}
|
||||
for node in graph["nodes"][:3]:
|
||||
node["data"][AGENT_NODE_JOB_DSL_KEY] = {"workflow_prompt": node["id"]}
|
||||
old_binding = SimpleNamespace(
|
||||
old_binding = WorkflowAgentNodeBinding(
|
||||
id="old-binding",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_version="draft",
|
||||
node_id="old-node",
|
||||
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
|
||||
agent_id="old-inline-agent",
|
||||
current_snapshot_id="old-snapshot",
|
||||
node_job_config={},
|
||||
created_by="account-1",
|
||||
)
|
||||
session = Mock()
|
||||
session.scalars.return_value.all.return_value = [old_binding]
|
||||
service = AgentDslService(session)
|
||||
sqlite_session.add(old_binding)
|
||||
sqlite_session.flush()
|
||||
service = AgentDslService(sqlite_session)
|
||||
imported_results = [
|
||||
SimpleNamespace(
|
||||
agent=SimpleNamespace(id=f"inline-agent-{index}"),
|
||||
@ -420,7 +441,7 @@ def test_import_workflow_packages_materializes_every_package_binding_as_inline()
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
)
|
||||
|
||||
session.delete.assert_called_once_with(old_binding)
|
||||
assert sqlite_session.get(WorkflowAgentNodeBinding, "old-binding") is None
|
||||
assert retirement_candidates == {"old-inline-agent"}
|
||||
assert service._create_imported_inline_agent.call_count == 3
|
||||
assert [call.kwargs["node_id"] for call in service._create_imported_inline_agent.call_args_list] == [
|
||||
@ -438,8 +459,10 @@ def test_import_workflow_packages_materializes_every_package_binding_as_inline()
|
||||
assert all(binding["binding_type"] == WorkflowAgentBindingType.INLINE_AGENT.value for binding in bindings)
|
||||
assert AGENT_NODE_JOB_DSL_KEY not in result["nodes"][0]["data"]
|
||||
assert json.loads(workflow.graph) == result
|
||||
added_bindings = [item.args[0] for item in session.add.call_args_list]
|
||||
assert all(isinstance(binding, WorkflowAgentNodeBinding) for binding in added_bindings)
|
||||
added_bindings = sqlite_session.scalars(
|
||||
select(WorkflowAgentNodeBinding).where(WorkflowAgentNodeBinding.workflow_id == "workflow-1")
|
||||
).all()
|
||||
assert len(added_bindings) == 3
|
||||
assert all(binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT for binding in added_bindings)
|
||||
|
||||
|
||||
@ -453,13 +476,13 @@ def test_import_workflow_packages_materializes_every_package_binding_as_inline()
|
||||
({"binding_type": "invalid", AGENT_PACKAGE_REF_KEY: "agent_1"}, "invalid binding type"),
|
||||
],
|
||||
)
|
||||
def test_import_workflow_packages_rejects_invalid_package_binding(binding: dict, error: str) -> None:
|
||||
session = Mock()
|
||||
session.scalars.return_value.all.return_value = []
|
||||
def test_import_workflow_packages_rejects_invalid_package_binding(
|
||||
binding: dict, error: str, sqlite_session: Session
|
||||
) -> None:
|
||||
package = make_portable_agent_package(_agent(), AgentSoulConfig())
|
||||
|
||||
with pytest.raises(ValueError, match=error):
|
||||
AgentDslService(session).import_workflow_packages(
|
||||
AgentDslService(sqlite_session).import_workflow_packages(
|
||||
workflow=SimpleNamespace(tenant_id="tenant-1", app_id="app-1", id="workflow-1", version="draft"),
|
||||
portable_graph={"nodes": [_agent_node("node-1", binding)], "edges": []},
|
||||
raw_packages={"agent_1": package.model_dump(mode="json")},
|
||||
@ -467,9 +490,8 @@ def test_import_workflow_packages_rejects_invalid_package_binding(binding: dict,
|
||||
)
|
||||
|
||||
|
||||
def test_clone_inline_binding_copies_soul() -> None:
|
||||
session = Mock()
|
||||
service = AgentDslService(session)
|
||||
def test_clone_inline_binding_copies_soul(unbound_session: Session) -> None:
|
||||
service = AgentDslService(unbound_session)
|
||||
target_agent = SimpleNamespace(id="target-agent")
|
||||
target_snapshot = SimpleNamespace(id="target-snapshot")
|
||||
service._create_workflow_only_agent = Mock(return_value=(target_agent, target_snapshot))
|
||||
@ -504,7 +526,9 @@ def test_clone_inline_binding_copies_soul() -> None:
|
||||
assert create_kwargs["source"] == AgentSource.WORKFLOW
|
||||
|
||||
|
||||
def test_extract_package_dependencies_covers_model_tools_and_knowledge(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_extract_package_dependencies_covers_model_tools_and_knowledge(
|
||||
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
|
||||
) -> None:
|
||||
model_dependency = Mock(side_effect=lambda provider: f"model:{provider}")
|
||||
tool_dependency = Mock(side_effect=lambda provider: f"tool:{provider}")
|
||||
monkeypatch.setattr(
|
||||
@ -551,7 +575,7 @@ def test_extract_package_dependencies_covers_model_tools_and_knowledge(monkeypat
|
||||
}
|
||||
)
|
||||
|
||||
dependencies = AgentDslService(Mock()).extract_package_dependencies(
|
||||
dependencies = AgentDslService(unbound_session).extract_package_dependencies(
|
||||
{"agent_1": make_portable_agent_package(_agent(), soul)}
|
||||
)
|
||||
|
||||
@ -564,8 +588,8 @@ def test_extract_package_dependencies_covers_model_tools_and_knowledge(monkeypat
|
||||
]
|
||||
|
||||
|
||||
def test_create_imported_inline_agent_uses_import_provenance() -> None:
|
||||
service = AgentDslService(Mock())
|
||||
def test_create_imported_inline_agent_uses_import_provenance(unbound_session: Session) -> None:
|
||||
service = AgentDslService(unbound_session)
|
||||
soul = AgentSoulConfig(config_note="inline")
|
||||
warning = DslImportWarning(code="setup", path="agent", message="setup")
|
||||
service._resolve_package_soul = Mock(return_value=(soul, [warning]))
|
||||
@ -587,9 +611,10 @@ def test_create_imported_inline_agent_uses_import_provenance() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_create_workflow_only_agent_sets_backing_app_and_snapshot(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session = Mock()
|
||||
service = AgentDslService(session)
|
||||
def test_create_workflow_only_agent_sets_backing_app_and_snapshot(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
service = AgentDslService(sqlite_session)
|
||||
roster_service = Mock()
|
||||
roster_service.create_hidden_backing_app_for_workflow_agent.return_value = SimpleNamespace(id="backing-app")
|
||||
monkeypatch.setattr("services.agent.dsl_service.AgentRosterService", Mock(return_value=roster_service))
|
||||
@ -613,11 +638,12 @@ def test_create_workflow_only_agent_sets_backing_app_and_snapshot(monkeypatch: p
|
||||
assert agent.active_config_snapshot_id == "snapshot-1"
|
||||
assert agent.active_config_has_model is True
|
||||
assert agent.active_config_is_published is True
|
||||
session.add.assert_called_once_with(agent)
|
||||
assert session.flush.call_count == 2
|
||||
assert sqlite_session.get(Agent, agent.id) is agent
|
||||
|
||||
|
||||
def test_resolve_package_soul_preserves_existing_and_marks_missing_knowledge(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_resolve_package_soul_preserves_existing_and_marks_missing_knowledge(
|
||||
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
|
||||
) -> None:
|
||||
soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"config_skills": [{"name": "skill", "file_kind": "tool_file", "file_id": "skill-file"}],
|
||||
@ -638,18 +664,17 @@ def test_resolve_package_soul_preserves_existing_and_marks_missing_knowledge(mon
|
||||
},
|
||||
}
|
||||
)
|
||||
session = Mock()
|
||||
get_dataset_rows = Mock(return_value={"existing": SimpleNamespace(id="existing")})
|
||||
monkeypatch.setattr("services.agent.dsl_service.get_tenant_knowledge_dataset_rows", get_dataset_rows)
|
||||
|
||||
resolved, warnings = AgentDslService(session)._resolve_package_soul(
|
||||
resolved, warnings = AgentDslService(unbound_session)._resolve_package_soul(
|
||||
tenant_id="tenant-1",
|
||||
package=make_portable_agent_package(_agent(), soul),
|
||||
package_path="agent_packages.agent_1",
|
||||
)
|
||||
|
||||
get_dataset_rows.assert_called_once_with(
|
||||
session=session,
|
||||
session=unbound_session,
|
||||
tenant_id="tenant-1",
|
||||
dataset_ids=["existing", "missing"],
|
||||
)
|
||||
@ -683,14 +708,18 @@ def test_resolve_package_soul_preserves_existing_and_marks_missing_knowledge(mon
|
||||
}
|
||||
|
||||
|
||||
def test_create_snapshot_increments_version_and_records_revision() -> None:
|
||||
session = Mock()
|
||||
session.scalar.return_value = 2
|
||||
service = AgentDslService(session)
|
||||
def test_create_snapshot_increments_version_and_records_revision(sqlite_session: Session) -> None:
|
||||
agent = _agent()
|
||||
first = _snapshot(snapshot_id="snapshot-1")
|
||||
second = _snapshot(snapshot_id="snapshot-2")
|
||||
second.version = 2
|
||||
sqlite_session.add_all([agent, first, second])
|
||||
sqlite_session.flush()
|
||||
service = AgentDslService(sqlite_session)
|
||||
|
||||
snapshot = service._create_snapshot(
|
||||
tenant_id="tenant-1",
|
||||
agent=_agent(),
|
||||
agent=agent,
|
||||
account_id="account-1",
|
||||
soul=AgentSoulConfig(config_note="version 3"),
|
||||
operation=AgentConfigRevisionOperation.IMPORT_PACKAGE,
|
||||
@ -698,28 +727,32 @@ def test_create_snapshot_increments_version_and_records_revision() -> None:
|
||||
|
||||
assert snapshot.version == 3
|
||||
assert snapshot.home_snapshot_id is None
|
||||
assert isinstance(session.add.call_args_list[0].args[0], AgentConfigSnapshot)
|
||||
revision = session.add.call_args_list[1].args[0]
|
||||
assert isinstance(revision, AgentConfigRevision)
|
||||
revision = sqlite_session.scalar(
|
||||
select(AgentConfigRevision).where(AgentConfigRevision.current_snapshot_id == snapshot.id)
|
||||
)
|
||||
assert revision is not None
|
||||
assert revision.operation == AgentConfigRevisionOperation.IMPORT_PACKAGE
|
||||
assert session.flush.call_count == 2
|
||||
|
||||
|
||||
def test_unique_roster_name_uses_first_available_suffix() -> None:
|
||||
session = Mock()
|
||||
session.scalars.return_value.all.return_value = ["Agent", "Agent import"]
|
||||
def test_unique_roster_name_uses_first_available_suffix(sqlite_session: Session) -> None:
|
||||
for index, name in enumerate(("Agent", "Agent import"), start=1):
|
||||
agent = _agent()
|
||||
agent.id = f"agent-{index}"
|
||||
agent.name = name
|
||||
sqlite_session.add(agent)
|
||||
sqlite_session.flush()
|
||||
|
||||
result = AgentDslService(session)._unique_roster_name(tenant_id="tenant-1", requested="Agent")
|
||||
result = AgentDslService(sqlite_session)._unique_roster_name(tenant_id="tenant-1", requested="Agent")
|
||||
|
||||
assert result == "Agent import 2"
|
||||
|
||||
|
||||
def test_require_helpers_and_graph_detection() -> None:
|
||||
session = Mock()
|
||||
service = AgentDslService(session)
|
||||
def test_require_helpers_and_graph_detection(sqlite_session: Session) -> None:
|
||||
service = AgentDslService(sqlite_session)
|
||||
agent = _agent()
|
||||
snapshot = _snapshot()
|
||||
session.scalar.side_effect = [agent, None, snapshot, None]
|
||||
sqlite_session.add_all([agent, snapshot])
|
||||
sqlite_session.flush()
|
||||
|
||||
assert service._require_agent(tenant_id="tenant-1", agent_id="agent-1") is agent
|
||||
with pytest.raises(ValueError, match="source Agent"):
|
||||
@ -733,17 +766,4 @@ def test_require_helpers_and_graph_detection() -> None:
|
||||
assert AgentDslService._agent_icon_type(AgentIconType.EMOJI.value) == AgentIconType.EMOJI
|
||||
assert AgentDslService._agent_icon_type(None) is None
|
||||
assert is_agent_v2_graph({"nodes": [_agent_node("agent")]}) is True
|
||||
assert is_agent_v2_graph({"nodes": [{"id": "legacy-agent", "data": {"type": "agent", "version": "2"}}]}) is False
|
||||
assert is_agent_v2_graph({"nodes": ["invalid", {"data": {"type": "start"}}]}) is False
|
||||
|
||||
|
||||
def test_export_workflow_packages_ignores_historical_agent_version_two() -> None:
|
||||
session = Mock()
|
||||
service = AgentDslService(session)
|
||||
graph = {"nodes": [{"id": "legacy-agent", "data": {"type": "agent", "version": "2"}}]}
|
||||
|
||||
portable_graph, packages = service.export_workflow_packages(workflow=Mock(), graph=graph)
|
||||
|
||||
assert portable_graph == graph
|
||||
assert packages == {}
|
||||
session.scalars.assert_not_called()
|
||||
|
||||
@ -12,6 +12,9 @@ from models.agent import (
|
||||
AgentConfigDraftType,
|
||||
AgentConfigSnapshot,
|
||||
AgentHomeSnapshot,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentStatus,
|
||||
AgentWorkingResourceStatus,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
@ -53,16 +56,31 @@ def test_home_snapshot_client_outlasts_the_gateway_snapshot_budget(monkeypatch:
|
||||
assert client._timeout == 45.0
|
||||
|
||||
|
||||
def test_validate_home_snapshot_binding_accepts_default_home_without_ledger_lookup() -> None:
|
||||
session = MagicMock()
|
||||
def _persist_agent(session: Session, *, app_id: str, backing_app_id: str | None) -> Agent:
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Snapshot Agent",
|
||||
description="",
|
||||
role="",
|
||||
scope=AgentScope.ROSTER if backing_app_id is None else AgentScope.WORKFLOW_ONLY,
|
||||
source=AgentSource.AGENT_APP if backing_app_id is None else AgentSource.WORKFLOW,
|
||||
status=AgentStatus.ACTIVE,
|
||||
app_id=app_id,
|
||||
backing_app_id=backing_app_id,
|
||||
)
|
||||
session.add(agent)
|
||||
session.commit()
|
||||
return agent
|
||||
|
||||
|
||||
def test_validate_home_snapshot_binding_accepts_default_home_without_ledger_lookup(unbound_session: Session) -> None:
|
||||
validate_home_snapshot_binding(
|
||||
session=session,
|
||||
session=unbound_session,
|
||||
agent=Agent(id="agent-1"),
|
||||
home_snapshot_id=None,
|
||||
)
|
||||
|
||||
session.scalar.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("app_id", "backing_app_id", "expected_runtime_app_id"),
|
||||
@ -73,12 +91,12 @@ def test_validate_home_snapshot_binding_accepts_default_home_without_ledger_look
|
||||
)
|
||||
def test_build_apply_checkpoints_exact_active_binding(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
app_id: str,
|
||||
backing_app_id: str | None,
|
||||
expected_runtime_app_id: str,
|
||||
) -> None:
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = SimpleNamespace(app_id=app_id, backing_app_id=backing_app_id)
|
||||
_persist_agent(sqlite_session, app_id=app_id, backing_app_id=backing_app_id)
|
||||
binding = SimpleNamespace(
|
||||
backend_binding_ref="binding-ref-1",
|
||||
agent_id="agent-1",
|
||||
@ -94,7 +112,7 @@ def test_build_apply_checkpoints_exact_active_binding(
|
||||
monkeypatch.setattr(AgentWorkspaceService, "validate_binding_generation", validate_generation)
|
||||
|
||||
snapshot = AgentHomeSnapshotService.create_for_build_apply(
|
||||
session=session,
|
||||
session=sqlite_session,
|
||||
build_draft=_build_draft(),
|
||||
)
|
||||
|
||||
@ -106,9 +124,8 @@ def test_build_apply_checkpoints_exact_active_binding(
|
||||
assert validate_generation.call_args.kwargs["base_home_snapshot_id"] == "home-old"
|
||||
|
||||
|
||||
def test_build_apply_forwards_default_home_generation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = SimpleNamespace(app_id="app-1", backing_app_id=None)
|
||||
def test_build_apply_forwards_default_home_generation(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
|
||||
_persist_agent(sqlite_session, app_id="app-1", backing_app_id=None)
|
||||
binding = SimpleNamespace(
|
||||
backend_binding_ref="binding-ref-1",
|
||||
agent_id="agent-1",
|
||||
@ -123,7 +140,7 @@ def test_build_apply_forwards_default_home_generation(monkeypatch: pytest.Monkey
|
||||
monkeypatch.setattr(AgentWorkspaceService, "validate_binding_generation", validate_generation)
|
||||
|
||||
snapshot = AgentHomeSnapshotService.create_for_build_apply(
|
||||
session=session,
|
||||
session=sqlite_session,
|
||||
build_draft=_build_draft(home_snapshot_id=None),
|
||||
)
|
||||
|
||||
@ -131,26 +148,26 @@ def test_build_apply_forwards_default_home_generation(monkeypatch: pytest.Monkey
|
||||
assert validate_generation.call_args.kwargs["base_home_snapshot_id"] is None
|
||||
|
||||
|
||||
def test_build_apply_fails_fast_without_source_binding() -> None:
|
||||
session = MagicMock()
|
||||
def test_build_apply_fails_fast_without_source_binding(unbound_session: Session) -> None:
|
||||
build_draft = _build_draft()
|
||||
build_draft.agent_workspace_binding_id = None
|
||||
|
||||
with pytest.raises(AgentBuildSandboxNotFoundError):
|
||||
AgentHomeSnapshotService.create_for_build_apply(
|
||||
session=session,
|
||||
session=unbound_session,
|
||||
build_draft=build_draft,
|
||||
)
|
||||
|
||||
|
||||
def test_home_snapshot_collection_database_failure_propagates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
context = MagicMock()
|
||||
session = context.__enter__.return_value
|
||||
def test_home_snapshot_collection_database_failure_propagates(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
error = RuntimeError("database unavailable")
|
||||
session.scalar.side_effect = error
|
||||
scalar = MagicMock(side_effect=error)
|
||||
monkeypatch.setattr(sqlite_session, "scalar", scalar)
|
||||
monkeypatch.setattr(
|
||||
"services.agent.home_snapshot_service.session_factory.create_session",
|
||||
lambda: context,
|
||||
lambda: nullcontext(sqlite_session),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
@ -159,6 +176,7 @@ def test_home_snapshot_collection_database_failure_propagates(monkeypatch: pytes
|
||||
home_snapshot_id="home-1",
|
||||
)
|
||||
|
||||
scalar.assert_called_once()
|
||||
assert exc_info.value is error
|
||||
|
||||
|
||||
|
||||
@ -7,15 +7,19 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigSnapshot,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentStatus,
|
||||
WorkflowAgentBindingType,
|
||||
WorkflowAgentNodeBinding,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.enums import AppStatus
|
||||
from models.model import App, AppMode
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
from services.agent.dsl_service import AgentDslService
|
||||
from services.agent.workflow_publish_service import WorkflowAgentPublishService, _InlineAgentOwnershipError
|
||||
from services.agent.workflow_publish_service import WorkflowAgentPublishService
|
||||
|
||||
|
||||
def _workflow(*, workflow_id: str = "workflow-1", version: str = Workflow.VERSION_DRAFT) -> Workflow:
|
||||
@ -33,39 +37,66 @@ def _workflow(*, workflow_id: str = "workflow-1", version: str = Workflow.VERSIO
|
||||
)
|
||||
|
||||
|
||||
def test_inline_binding_from_another_node_is_cloned(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session = Mock()
|
||||
draft_workflow = _workflow()
|
||||
monkeypatch.setattr(
|
||||
WorkflowAgentPublishService,
|
||||
"_resolve_inline_agent_graph_binding",
|
||||
Mock(side_effect=_InlineAgentOwnershipError("source belongs to another node")),
|
||||
def _inline_agent(
|
||||
*,
|
||||
agent_id: str,
|
||||
workflow_id: str,
|
||||
node_id: str,
|
||||
tenant_id: str = "tenant-1",
|
||||
) -> Agent:
|
||||
return Agent(
|
||||
id=agent_id,
|
||||
tenant_id=tenant_id,
|
||||
name=f"Inline {agent_id}",
|
||||
scope=AgentScope.WORKFLOW_ONLY,
|
||||
source=AgentSource.WORKFLOW,
|
||||
status=AgentStatus.ACTIVE,
|
||||
app_id="app-1",
|
||||
workflow_id=workflow_id,
|
||||
workflow_node_id=node_id,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
WorkflowAgentPublishService,
|
||||
"_resolve_existing_inline_binding_agent",
|
||||
Mock(return_value=None),
|
||||
|
||||
|
||||
def _snapshot(*, snapshot_id: str, agent_id: str, version: int = 1) -> AgentConfigSnapshot:
|
||||
return AgentConfigSnapshot(
|
||||
id=snapshot_id,
|
||||
tenant_id="tenant-1",
|
||||
agent_id=agent_id,
|
||||
version=version,
|
||||
config_snapshot=AgentSoulConfig(),
|
||||
)
|
||||
clone = Mock(return_value=(SimpleNamespace(id="target-agent"), "target-snapshot"))
|
||||
monkeypatch.setattr(WorkflowAgentPublishService, "_clone_inline_graph_binding_for_node", clone)
|
||||
|
||||
|
||||
def test_inline_binding_from_another_node_is_cloned(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
|
||||
source_agent = _inline_agent(agent_id="source-agent", workflow_id="workflow-1", node_id="source-node")
|
||||
source_snapshot = _snapshot(snapshot_id="source-snapshot", agent_id=source_agent.id)
|
||||
sqlite_session.add_all([source_agent, source_snapshot])
|
||||
sqlite_session.commit()
|
||||
target_agent = _inline_agent(agent_id="target-agent", workflow_id="workflow-1", node_id="pasted-node")
|
||||
target_snapshot = _snapshot(snapshot_id="target-snapshot", agent_id=target_agent.id)
|
||||
clone = Mock(return_value=(target_agent, target_snapshot))
|
||||
monkeypatch.setattr(AgentDslService, "clone_inline_binding_for_node", clone)
|
||||
|
||||
WorkflowAgentPublishService._sync_agent_binding_for_node(
|
||||
session=session,
|
||||
draft_workflow=draft_workflow,
|
||||
session=sqlite_session,
|
||||
draft_workflow=_workflow(),
|
||||
node_id="pasted-node",
|
||||
node_data={"agent_task": "Summarize the input"},
|
||||
node_binding={
|
||||
"binding_type": WorkflowAgentBindingType.INLINE_AGENT.value,
|
||||
"agent_id": "source-agent",
|
||||
"current_snapshot_id": "source-snapshot",
|
||||
"agent_id": source_agent.id,
|
||||
"current_snapshot_id": source_snapshot.id,
|
||||
},
|
||||
existing_binding=None,
|
||||
account_id="account-1",
|
||||
)
|
||||
sqlite_session.flush()
|
||||
|
||||
clone.assert_called_once()
|
||||
binding = session.add.call_args.args[0]
|
||||
assert isinstance(binding, WorkflowAgentNodeBinding)
|
||||
binding = sqlite_session.scalar(
|
||||
select(WorkflowAgentNodeBinding).where(WorkflowAgentNodeBinding.node_id == "pasted-node")
|
||||
)
|
||||
assert binding is not None
|
||||
assert binding.agent_id == "target-agent"
|
||||
assert binding.current_snapshot_id == "target-snapshot"
|
||||
assert binding.node_job_config.workflow_prompt == "Summarize the input"
|
||||
@ -103,8 +134,9 @@ def test_draft_sync_resolves_roster_agents() -> None:
|
||||
assert {call.args[0].agent_id for call in session.add.call_args_list} == {"agent-a", "agent-b"}
|
||||
|
||||
|
||||
def test_restore_replaces_bindings_and_returns_only_replaced_inline_agent() -> None:
|
||||
def test_restore_replaces_bindings_and_returns_only_replaced_inline_agent(sqlite_session: Session) -> None:
|
||||
existing_inline = WorkflowAgentNodeBinding(
|
||||
id="existing-inline",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="draft-workflow",
|
||||
@ -117,6 +149,7 @@ def test_restore_replaces_bindings_and_returns_only_replaced_inline_agent() -> N
|
||||
created_by="account-1",
|
||||
)
|
||||
existing_roster = WorkflowAgentNodeBinding(
|
||||
id="existing-roster",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="draft-workflow",
|
||||
@ -129,6 +162,7 @@ def test_restore_replaces_bindings_and_returns_only_replaced_inline_agent() -> N
|
||||
created_by="account-1",
|
||||
)
|
||||
source = WorkflowAgentNodeBinding(
|
||||
id="source-roster",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="published-workflow",
|
||||
@ -140,30 +174,34 @@ def test_restore_replaces_bindings_and_returns_only_replaced_inline_agent() -> N
|
||||
node_job_config={"workflow_prompt": "Use the roster agent"},
|
||||
created_by="account-1",
|
||||
)
|
||||
session = Mock()
|
||||
session.scalars.side_effect = [
|
||||
SimpleNamespace(all=lambda: [existing_inline, existing_roster]),
|
||||
SimpleNamespace(all=lambda: [source]),
|
||||
]
|
||||
session.scalar.return_value = SimpleNamespace(
|
||||
roster_agent = Agent(
|
||||
id="roster-agent",
|
||||
tenant_id="tenant-1",
|
||||
name="Roster Agent",
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.ROSTER,
|
||||
status=AgentStatus.ACTIVE,
|
||||
app_id="roster-app",
|
||||
active_config_snapshot_id="published-snapshot",
|
||||
)
|
||||
sqlite_session.add_all([existing_inline, existing_roster, source, roster_agent])
|
||||
sqlite_session.commit()
|
||||
retirement_candidates = WorkflowAgentPublishService.restore_agent_node_bindings_to_draft(
|
||||
session=session,
|
||||
session=sqlite_session,
|
||||
source_workflow=_workflow(workflow_id="published-workflow", version="2026-07-13 00:00:00"),
|
||||
draft_workflow=_workflow(workflow_id="draft-workflow"),
|
||||
account_id="account-2",
|
||||
)
|
||||
|
||||
assert {item.args[0].agent_id for item in session.delete.call_args_list} == {
|
||||
"old-inline-agent",
|
||||
"old-roster-agent",
|
||||
}
|
||||
restored = session.add.call_args.args[0]
|
||||
assert isinstance(restored, WorkflowAgentNodeBinding)
|
||||
assert restored.workflow_id == "draft-workflow"
|
||||
assert sqlite_session.get(WorkflowAgentNodeBinding, existing_inline.id) is None
|
||||
assert sqlite_session.get(WorkflowAgentNodeBinding, existing_roster.id) is None
|
||||
restored = sqlite_session.scalar(
|
||||
select(WorkflowAgentNodeBinding).where(
|
||||
WorkflowAgentNodeBinding.workflow_id == "draft-workflow",
|
||||
WorkflowAgentNodeBinding.node_id == "agent-node",
|
||||
)
|
||||
)
|
||||
assert restored is not None
|
||||
assert restored.workflow_version == Workflow.VERSION_DRAFT
|
||||
assert restored.agent_id == "roster-agent"
|
||||
assert restored.current_snapshot_id == "published-snapshot"
|
||||
@ -284,6 +322,7 @@ def test_publish_binding_copy_keeps_previous_published_owner(
|
||||
draft_workflow=draft_workflow,
|
||||
published_workflow=published_workflow,
|
||||
)
|
||||
sqlite_session.flush()
|
||||
|
||||
assert result is True
|
||||
assert sqlite_session.get(WorkflowAgentNodeBinding, previous_inline_binding.id) is previous_inline_binding
|
||||
@ -299,55 +338,50 @@ def test_publish_binding_copy_keeps_previous_published_owner(
|
||||
assert copied.current_snapshot_id == "draft-inline-snapshot"
|
||||
|
||||
|
||||
def test_inline_binding_reuses_existing_node_owned_agent(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session = Mock()
|
||||
draft_workflow = _workflow()
|
||||
def test_inline_binding_reuses_existing_node_owned_agent(sqlite_session: Session) -> None:
|
||||
existing_agent = _inline_agent(agent_id="existing-agent", workflow_id="workflow-1", node_id="pasted-node")
|
||||
existing_snapshot = _snapshot(snapshot_id="existing-snapshot", agent_id=existing_agent.id)
|
||||
existing_binding = WorkflowAgentNodeBinding(
|
||||
id="existing-binding",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_version=Workflow.VERSION_DRAFT,
|
||||
node_id="pasted-node",
|
||||
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
|
||||
agent_id="existing-agent",
|
||||
current_snapshot_id="existing-snapshot",
|
||||
agent_id=existing_agent.id,
|
||||
current_snapshot_id=existing_snapshot.id,
|
||||
node_job_config={},
|
||||
created_by="account-1",
|
||||
)
|
||||
existing_agent = SimpleNamespace(id="existing-agent")
|
||||
monkeypatch.setattr(
|
||||
WorkflowAgentPublishService,
|
||||
"_resolve_inline_agent_graph_binding",
|
||||
Mock(side_effect=_InlineAgentOwnershipError("source belongs to another node")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
WorkflowAgentPublishService,
|
||||
"_resolve_existing_inline_binding_agent",
|
||||
Mock(return_value=existing_agent),
|
||||
)
|
||||
clone = Mock()
|
||||
monkeypatch.setattr(WorkflowAgentPublishService, "_clone_inline_graph_binding_for_node", clone)
|
||||
sqlite_session.add_all([existing_agent, existing_snapshot, existing_binding])
|
||||
sqlite_session.commit()
|
||||
|
||||
WorkflowAgentPublishService._sync_agent_binding_for_node(
|
||||
session=session,
|
||||
draft_workflow=draft_workflow,
|
||||
session=sqlite_session,
|
||||
draft_workflow=_workflow(),
|
||||
node_id="pasted-node",
|
||||
node_data={"agent_task": "Summarize"},
|
||||
node_binding={
|
||||
"binding_type": WorkflowAgentBindingType.INLINE_AGENT.value,
|
||||
"agent_id": "source-agent",
|
||||
"current_snapshot_id": "source-snapshot",
|
||||
"agent_id": "unavailable-source-agent",
|
||||
"current_snapshot_id": "unavailable-source-snapshot",
|
||||
},
|
||||
existing_binding=existing_binding,
|
||||
account_id="account-1",
|
||||
)
|
||||
sqlite_session.flush()
|
||||
|
||||
assert existing_binding.agent_id == "existing-agent"
|
||||
assert existing_binding.current_snapshot_id == "existing-snapshot"
|
||||
clone.assert_not_called()
|
||||
stored = sqlite_session.get(WorkflowAgentNodeBinding, existing_binding.id)
|
||||
assert stored is not None
|
||||
assert stored.agent_id == "existing-agent"
|
||||
assert stored.current_snapshot_id == "existing-snapshot"
|
||||
assert stored.node_job_config.workflow_prompt == "Summarize"
|
||||
|
||||
|
||||
def test_resolve_existing_inline_binding_agent_returns_valid_agent_or_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_resolve_existing_inline_binding_agent_returns_valid_agent_or_none(
|
||||
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
|
||||
) -> None:
|
||||
binding = WorkflowAgentNodeBinding(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
@ -360,13 +394,13 @@ def test_resolve_existing_inline_binding_agent_returns_valid_agent_or_none(monke
|
||||
node_job_config={},
|
||||
created_by="account-1",
|
||||
)
|
||||
resolved = SimpleNamespace(id="agent-1")
|
||||
resolved = _inline_agent(agent_id="agent-1", workflow_id="workflow-1", node_id="node-1")
|
||||
resolver = Mock(return_value=resolved)
|
||||
monkeypatch.setattr(WorkflowAgentPublishService, "_resolve_inline_agent_graph_binding", resolver)
|
||||
|
||||
assert (
|
||||
WorkflowAgentPublishService._resolve_existing_inline_binding_agent(
|
||||
session=Mock(),
|
||||
session=unbound_session,
|
||||
draft_workflow=_workflow(),
|
||||
node_id="node-1",
|
||||
existing_binding=binding,
|
||||
@ -377,7 +411,7 @@ def test_resolve_existing_inline_binding_agent_returns_valid_agent_or_none(monke
|
||||
resolver.side_effect = ValueError("stale")
|
||||
assert (
|
||||
WorkflowAgentPublishService._resolve_existing_inline_binding_agent(
|
||||
session=Mock(),
|
||||
session=unbound_session,
|
||||
draft_workflow=_workflow(),
|
||||
node_id="node-1",
|
||||
existing_binding=binding,
|
||||
@ -386,30 +420,42 @@ def test_resolve_existing_inline_binding_agent_returns_valid_agent_or_none(monke
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_roster_binding_rejects_unpublished_agent() -> None:
|
||||
session = Mock()
|
||||
session.scalar.return_value = None
|
||||
def test_resolve_roster_binding_rejects_unpublished_agent(sqlite_session: Session) -> None:
|
||||
sqlite_session.add(
|
||||
Agent(
|
||||
id="decoy-agent",
|
||||
tenant_id="tenant-1",
|
||||
name="Decoy",
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
app_id="decoy-app",
|
||||
)
|
||||
)
|
||||
sqlite_session.commit()
|
||||
with pytest.raises(ValueError, match="unavailable or unpublished roster agent"):
|
||||
WorkflowAgentPublishService._resolve_roster_agent_graph_binding(
|
||||
session=session,
|
||||
session=sqlite_session,
|
||||
draft_workflow=_workflow(),
|
||||
node_id="agent-node",
|
||||
agent_id="agent-1",
|
||||
)
|
||||
|
||||
|
||||
def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session = Mock()
|
||||
source_agent = SimpleNamespace(id="source-agent")
|
||||
source_snapshot = SimpleNamespace(id="source-snapshot")
|
||||
session.scalar.side_effect = [source_agent, source_snapshot]
|
||||
target_agent = SimpleNamespace(id="target-agent")
|
||||
target_snapshot = SimpleNamespace(id="target-snapshot")
|
||||
def test_clone_inline_graph_binding_for_node_clones_source(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
source_agent = _inline_agent(agent_id="source-agent", workflow_id="source-workflow", node_id="source-node")
|
||||
source_snapshot = _snapshot(snapshot_id="source-snapshot", agent_id=source_agent.id)
|
||||
sqlite_session.add_all([source_agent, source_snapshot])
|
||||
sqlite_session.commit()
|
||||
target_agent = _inline_agent(agent_id="target-agent", workflow_id="workflow-1", node_id="target-node")
|
||||
target_snapshot = _snapshot(snapshot_id="target-snapshot", agent_id=target_agent.id)
|
||||
clone = Mock(return_value=(target_agent, target_snapshot))
|
||||
monkeypatch.setattr(AgentDslService, "clone_inline_binding_for_node", clone)
|
||||
|
||||
result = WorkflowAgentPublishService._clone_inline_graph_binding_for_node(
|
||||
session=session,
|
||||
session=sqlite_session,
|
||||
draft_workflow=_workflow(),
|
||||
node_id="target-node",
|
||||
source_agent_id="source-agent",
|
||||
@ -427,14 +473,17 @@ def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch: pytest.M
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scalar_results", [[None], [SimpleNamespace(id="source-agent"), None]])
|
||||
def test_clone_inline_graph_binding_for_node_rejects_missing_source(scalar_results: list[object | None]) -> None:
|
||||
session = Mock()
|
||||
session.scalar.side_effect = scalar_results
|
||||
@pytest.mark.parametrize("persist_source_agent", [False, True])
|
||||
def test_clone_inline_graph_binding_for_node_rejects_missing_source(
|
||||
sqlite_session: Session, persist_source_agent: bool
|
||||
) -> None:
|
||||
if persist_source_agent:
|
||||
sqlite_session.add(_inline_agent(agent_id="source-agent", workflow_id="source-workflow", node_id="source-node"))
|
||||
sqlite_session.commit()
|
||||
|
||||
with pytest.raises(ValueError, match="unavailable inline agent|missing inline agent config snapshot"):
|
||||
WorkflowAgentPublishService._clone_inline_graph_binding_for_node(
|
||||
session=session,
|
||||
session=sqlite_session,
|
||||
draft_workflow=_workflow(),
|
||||
node_id="target-node",
|
||||
source_agent_id="source-agent",
|
||||
@ -443,37 +492,45 @@ def test_clone_inline_graph_binding_for_node_rejects_missing_source(scalar_resul
|
||||
)
|
||||
|
||||
|
||||
def test_restore_clones_inline_binding_owned_by_published_workflow(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_restore_clones_inline_binding_owned_by_published_workflow(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
source_agent = _inline_agent(agent_id="published-agent", workflow_id="published-workflow", node_id="agent-node")
|
||||
source_snapshot = _snapshot(snapshot_id="published-snapshot", agent_id=source_agent.id)
|
||||
source = WorkflowAgentNodeBinding(
|
||||
id="published-binding",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="published-workflow",
|
||||
workflow_version="published",
|
||||
node_id="agent-node",
|
||||
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
|
||||
agent_id="published-agent",
|
||||
current_snapshot_id="published-snapshot",
|
||||
agent_id=source_agent.id,
|
||||
current_snapshot_id=source_snapshot.id,
|
||||
node_job_config={"workflow_prompt": "work"},
|
||||
created_by="account-1",
|
||||
)
|
||||
session = Mock()
|
||||
session.scalars.side_effect = [SimpleNamespace(all=lambda: []), SimpleNamespace(all=lambda: [source])]
|
||||
monkeypatch.setattr(
|
||||
WorkflowAgentPublishService,
|
||||
"_resolve_inline_agent_graph_binding",
|
||||
Mock(side_effect=ValueError("owned by published workflow")),
|
||||
)
|
||||
clone = Mock(return_value=(SimpleNamespace(id="draft-agent"), "draft-snapshot"))
|
||||
monkeypatch.setattr(WorkflowAgentPublishService, "_clone_inline_graph_binding_for_node", clone)
|
||||
sqlite_session.add_all([source_agent, source_snapshot, source])
|
||||
sqlite_session.commit()
|
||||
target_agent = _inline_agent(agent_id="draft-agent", workflow_id="draft-workflow", node_id="agent-node")
|
||||
target_snapshot = _snapshot(snapshot_id="draft-snapshot", agent_id=target_agent.id)
|
||||
clone = Mock(return_value=(target_agent, target_snapshot))
|
||||
monkeypatch.setattr(AgentDslService, "clone_inline_binding_for_node", clone)
|
||||
|
||||
WorkflowAgentPublishService.restore_agent_node_bindings_to_draft(
|
||||
session=session,
|
||||
session=sqlite_session,
|
||||
source_workflow=_workflow(workflow_id="published-workflow", version="published"),
|
||||
draft_workflow=_workflow(workflow_id="draft-workflow"),
|
||||
account_id="account-2",
|
||||
)
|
||||
|
||||
clone.assert_called_once()
|
||||
restored = session.add.call_args.args[0]
|
||||
restored = sqlite_session.scalar(
|
||||
select(WorkflowAgentNodeBinding).where(
|
||||
WorkflowAgentNodeBinding.workflow_id == "draft-workflow",
|
||||
WorkflowAgentNodeBinding.workflow_version == Workflow.VERSION_DRAFT,
|
||||
)
|
||||
)
|
||||
assert restored is not None
|
||||
assert restored.agent_id == "draft-agent"
|
||||
assert restored.current_snapshot_id == "draft-snapshot"
|
||||
|
||||
@ -105,11 +105,6 @@ def test_workspace_client_honors_the_configured_snapshot_timeout(monkeypatch: py
|
||||
assert client._timeout == 123.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(AgentHomeSnapshot, AgentWorkspace, AgentWorkspaceBinding)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_create_binding_success_persists_new_workspace_and_binding(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
@ -148,11 +143,6 @@ def test_create_binding_success_persists_new_workspace_and_binding(
|
||||
assert request.home_snapshot_ref == "home-ref"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(AgentHomeSnapshot, AgentWorkspace, AgentWorkspaceBinding)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_create_binding_without_home_snapshot_uses_backend_default(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
@ -176,11 +166,6 @@ def test_create_binding_without_home_snapshot_uses_backend_default(
|
||||
assert request.home_snapshot_ref is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(AgentHomeSnapshot, AgentWorkspace, AgentWorkspaceBinding)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_create_binding_rejects_missing_explicit_home_snapshot_before_backend_call(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
@ -200,11 +185,6 @@ def test_create_binding_rejects_missing_explicit_home_snapshot_before_backend_ca
|
||||
client.create_execution_binding_sync.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(AgentHomeSnapshot, AgentWorkspace, AgentWorkspaceBinding)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_create_second_binding_reuses_existing_workspace(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
@ -243,7 +223,6 @@ def test_create_second_binding_reuses_existing_workspace(
|
||||
assert request.workspace_id == workspace.id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True)
|
||||
def test_get_active_binding_resolves_exact_participant(sqlite_session: Session) -> None:
|
||||
conversation_workspace = _workspace(workspace_id="workspace-conversation")
|
||||
build_workspace = _workspace(
|
||||
@ -274,7 +253,6 @@ def test_get_active_binding_resolves_exact_participant(sqlite_session: Session)
|
||||
assert resolved.id == conversation_binding.id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True)
|
||||
def test_get_active_binding_rejects_wrong_owner(sqlite_session: Session) -> None:
|
||||
build_workspace = _workspace(
|
||||
workspace_id="workspace-build",
|
||||
@ -299,7 +277,6 @@ def test_get_active_binding_rejects_wrong_owner(sqlite_session: Session) -> None
|
||||
assert resolved is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True)
|
||||
def test_retire_non_final_binding_keeps_workspace_active(sqlite_session: Session) -> None:
|
||||
binding = _binding()
|
||||
other_binding = _binding(binding_id="binding-2", agent_id="agent-2")
|
||||
@ -320,7 +297,6 @@ def test_retire_non_final_binding_keeps_workspace_active(sqlite_session: Session
|
||||
assert other_binding.status is AgentWorkingResourceStatus.ACTIVE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True)
|
||||
def test_retire_final_binding_retires_workspace(sqlite_session: Session) -> None:
|
||||
binding = _binding()
|
||||
workspace = _workspace()
|
||||
@ -335,15 +311,14 @@ def test_retire_final_binding_retires_workspace(sqlite_session: Session) -> None
|
||||
assert workspace.retired_at == binding.retired_at
|
||||
|
||||
|
||||
def test_retire_workspace_retires_all_active_bindings() -> None:
|
||||
def test_retire_workspace_retires_all_active_bindings(sqlite_session: Session) -> None:
|
||||
workspace = _workspace()
|
||||
bindings = [_binding(), _binding(binding_id="binding-2", agent_id="agent-2")]
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = workspace
|
||||
session.scalars.return_value.all.return_value = bindings
|
||||
sqlite_session.add_all([workspace, *bindings])
|
||||
sqlite_session.flush()
|
||||
|
||||
retired_id = AgentWorkspaceService.retire_workspace(
|
||||
session=session,
|
||||
session=sqlite_session,
|
||||
tenant_id="tenant-1",
|
||||
workspace_id=workspace.id,
|
||||
)
|
||||
@ -354,7 +329,6 @@ def test_retire_workspace_retires_all_active_bindings() -> None:
|
||||
assert all(binding.retired_at == workspace.retired_at for binding in bindings)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True)
|
||||
def test_retire_all_for_app_retires_only_active_workspaces_for_that_app(sqlite_session: Session) -> None:
|
||||
active = _workspace(workspace_id="workspace-active", owner_id="conversation-active")
|
||||
already_retired = _workspace(
|
||||
@ -390,7 +364,6 @@ def test_retire_all_for_app_retires_only_active_workspaces_for_that_app(sqlite_s
|
||||
assert other_binding.status is AgentWorkingResourceStatus.ACTIVE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True)
|
||||
def test_collect_binding_without_retired_workspace_destroys_binding_only(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
@ -414,7 +387,6 @@ def test_collect_binding_without_retired_workspace_destroys_binding_only(
|
||||
assert sqlite_session.get(AgentWorkspace, workspace.id) is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True)
|
||||
def test_collect_workspace_destroys_workspace_then_remaining_bindings(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
|
||||
@ -997,7 +997,7 @@ class TestMemberRoles:
|
||||
}
|
||||
assert persisted_joins == {
|
||||
"acct-2": svc.TenantAccountRole.OWNER,
|
||||
"acct-owner": svc.TenantAccountRole.ADMIN,
|
||||
"acct-owner": svc.TenantAccountRole.NORMAL,
|
||||
}
|
||||
assert out.roles[0].id == "owner"
|
||||
|
||||
@ -1128,16 +1128,12 @@ class TestListOption:
|
||||
|
||||
class TestLegacyAgentManageKey:
|
||||
def test_legacy_agent_manage_key_membership(self):
|
||||
# Mirrors the builtin roles in the rbac service, which grant agent.manage
|
||||
# to owner/admin/editor only.
|
||||
# Preserve Agent access for every legacy role while external RBAC is disabled.
|
||||
for keys in (
|
||||
svc._LEGACY_WORKSPACE_OWNER_KEYS,
|
||||
svc._LEGACY_WORKSPACE_ADMIN_KEYS,
|
||||
svc._LEGACY_WORKSPACE_EDITOR_KEYS,
|
||||
):
|
||||
assert "agent.manage" in keys
|
||||
for keys in (
|
||||
svc._LEGACY_WORKSPACE_NORMAL_KEYS,
|
||||
svc._LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS,
|
||||
):
|
||||
assert "agent.manage" not in keys
|
||||
assert "agent.manage" in keys
|
||||
|
||||
@ -0,0 +1,176 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
from models.account import Account
|
||||
from services.account_email_registration_adapters import (
|
||||
AccountServiceRegistrationGateway,
|
||||
BillingAccountRegistrationPolicyGateway,
|
||||
RedisEmailRegistrationSecurityGateway,
|
||||
TokenManagerEmailRegistrationTokenGateway,
|
||||
)
|
||||
from services.account_errors import (
|
||||
AccountEmailDomainSuspendedError,
|
||||
AccountNormalizedEmailAlreadyInUseError,
|
||||
EmailRegistrationSeatsLimitError,
|
||||
)
|
||||
from services.account_service import TokenPair
|
||||
from services.entities.account_entities import AccountEmailRegistrationPhase, AccountEmailRegistrationToken
|
||||
from services.errors.account import (
|
||||
AccountNormalizedEmailAlreadyInUseError as AccountNormalizedEmailAlreadyInUseServiceError,
|
||||
)
|
||||
from services.errors.account import EmailDomainSuspendedError, SeatsLimitExceededError
|
||||
|
||||
|
||||
def test_token_gateway_rejects_malformed_payload() -> None:
|
||||
gateway = TokenManagerEmailRegistrationTokenGateway()
|
||||
|
||||
with patch(
|
||||
"services.account_email_registration_adapters.TokenManager.get_token_data",
|
||||
return_value={"email": "user@example.com", "phase": "unknown"},
|
||||
):
|
||||
assert gateway.get("token") is None
|
||||
|
||||
|
||||
def test_token_gateway_issues_verified_registration_state() -> None:
|
||||
gateway = TokenManagerEmailRegistrationTokenGateway()
|
||||
token_data = AccountEmailRegistrationToken(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
phase=AccountEmailRegistrationPhase.REGISTER,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"services.account_email_registration_adapters.TokenManager.generate_token",
|
||||
return_value="token",
|
||||
) as generate_token:
|
||||
assert gateway.issue(token_data) == "token"
|
||||
|
||||
generate_token.assert_called_once_with(
|
||||
email="user@example.com",
|
||||
token_type="email_register",
|
||||
additional_data={"code": "123456", "phase": "register"},
|
||||
)
|
||||
|
||||
|
||||
def test_security_gateway_delegates_ip_limit_to_existing_policy_owner() -> None:
|
||||
redis = Mock(spec=RedisClientWrapper)
|
||||
gateway = RedisEmailRegistrationSecurityGateway(
|
||||
redis=redis,
|
||||
verification_failure_limit=5,
|
||||
verification_lockout_duration=600,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"services.account_email_registration_adapters.AccountService.is_email_send_ip_limit",
|
||||
return_value=False,
|
||||
) as is_email_send_ip_limit:
|
||||
assert gateway.is_ip_limited("127.0.0.1") is False
|
||||
|
||||
is_email_send_ip_limit.assert_called_once_with("127.0.0.1")
|
||||
redis.get.assert_not_called()
|
||||
|
||||
|
||||
def test_security_gateway_uses_registration_and_login_keys() -> None:
|
||||
redis = Mock(spec=RedisClientWrapper)
|
||||
redis.get.return_value = 1
|
||||
gateway = RedisEmailRegistrationSecurityGateway(
|
||||
redis=redis,
|
||||
verification_failure_limit=5,
|
||||
verification_lockout_duration=600,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"services.account_email_registration_adapters.AccountService.reset_login_error_rate_limit"
|
||||
) as reset_login_error_rate_limit:
|
||||
gateway.record_verification_failure("user@example.com")
|
||||
gateway.reset_verification_failures("user@example.com")
|
||||
gateway.reset_login_failures("user@example.com")
|
||||
|
||||
redis.setex.assert_called_once_with("email_register_error_rate_limit:user@example.com", 600, 2)
|
||||
redis.delete.assert_called_once_with("email_register_error_rate_limit:user@example.com")
|
||||
reset_login_error_rate_limit.assert_called_once_with("user@example.com")
|
||||
|
||||
|
||||
def test_billing_policy_is_disabled_outside_cloud() -> None:
|
||||
gateway = BillingAccountRegistrationPolicyGateway(enabled=False)
|
||||
|
||||
with patch("services.account_email_registration_adapters.BillingService.get_email_freeze_type") as freeze_type:
|
||||
assert gateway.get_freeze_type("user@example.com") is None
|
||||
|
||||
freeze_type.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service_error", "application_error"),
|
||||
[
|
||||
pytest.param(SeatsLimitExceededError(), EmailRegistrationSeatsLimitError, id="seat-limit"),
|
||||
pytest.param(EmailDomainSuspendedError(), AccountEmailDomainSuspendedError, id="suspended-domain"),
|
||||
pytest.param(
|
||||
AccountNormalizedEmailAlreadyInUseServiceError(),
|
||||
AccountNormalizedEmailAlreadyInUseError,
|
||||
id="normalized-email-in-use",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_registration_gateway_translates_account_provisioning_errors(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
service_error: Exception,
|
||||
application_error: type[Exception],
|
||||
) -> None:
|
||||
gateway = AccountServiceRegistrationGateway(session_factory=sqlite_session_factory)
|
||||
|
||||
with patch(
|
||||
"services.account_email_registration_adapters.AccountService.create_account_and_tenant",
|
||||
side_effect=service_error,
|
||||
):
|
||||
with pytest.raises(application_error):
|
||||
gateway.create(
|
||||
email="user@example.com",
|
||||
password="ValidPass123!",
|
||||
interface_language="en-US",
|
||||
timezone=None,
|
||||
ip_address="127.0.0.1",
|
||||
)
|
||||
|
||||
|
||||
def test_registration_gateway_owns_short_lived_sessions(
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
gateway = AccountServiceRegistrationGateway(session_factory=sqlite_session_factory)
|
||||
|
||||
def create_account(*, session: Session, **_: object) -> Account:
|
||||
account = Account(name="user@example.com", email="user@example.com")
|
||||
account.id = "account-1"
|
||||
session.add(account)
|
||||
session.commit()
|
||||
return account
|
||||
|
||||
with patch(
|
||||
"services.account_email_registration_adapters.AccountService.create_account_and_tenant",
|
||||
side_effect=create_account,
|
||||
) as create_account_and_tenant:
|
||||
account_id = gateway.create(
|
||||
email="user@example.com",
|
||||
password="ValidPass123!",
|
||||
interface_language="en-US",
|
||||
timezone=None,
|
||||
ip_address="127.0.0.1",
|
||||
)
|
||||
|
||||
assert create_account_and_tenant.call_args.kwargs["check_normalized_email"] is True
|
||||
sqlite_session.expire_all()
|
||||
assert sqlite_session.get(Account, account_id) is not None
|
||||
|
||||
with patch(
|
||||
"services.account_email_registration_adapters.AccountService.login",
|
||||
return_value=TokenPair(access_token="access", refresh_token="refresh", csrf_token="csrf"),
|
||||
) as login:
|
||||
tokens = gateway.login(account_id, ip_address="127.0.0.1")
|
||||
|
||||
assert tokens.access_token == "access"
|
||||
assert login.call_args.kwargs["account"].id == account_id
|
||||
assert isinstance(login.call_args.kwargs["session"], Session)
|
||||
@ -0,0 +1,267 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from services.account_email_registration_service import (
|
||||
AccountEmailRegistrationService,
|
||||
AccountRegistrationGateway,
|
||||
AccountRegistrationPolicyGateway,
|
||||
EmailRegistrationCodeGenerator,
|
||||
EmailRegistrationNotificationGateway,
|
||||
EmailRegistrationSecurityGateway,
|
||||
EmailRegistrationSendLimiter,
|
||||
EmailRegistrationTokenGateway,
|
||||
)
|
||||
from services.account_errors import (
|
||||
AccountEmailAlreadyInUseError,
|
||||
AccountEmailDomainSuspendedError,
|
||||
EmailRegistrationPasswordMismatchError,
|
||||
InvalidEmailRegistrationCodeError,
|
||||
InvalidEmailRegistrationTokenError,
|
||||
)
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.account_entities import (
|
||||
AccountEmailRegistrationPhase,
|
||||
AccountEmailRegistrationToken,
|
||||
AccountSessionTokens,
|
||||
AccountSnapshot,
|
||||
)
|
||||
|
||||
|
||||
def _account(*, email: str = "stored@example.com") -> AccountSnapshot:
|
||||
return AccountSnapshot(
|
||||
id="account-1",
|
||||
name="Stored Account",
|
||||
email=email,
|
||||
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() -> tuple[AccountEmailRegistrationService, dict[str, Mock]]:
|
||||
dependencies = {
|
||||
"accounts": Mock(spec=AccountRepository),
|
||||
"tokens": Mock(spec=EmailRegistrationTokenGateway),
|
||||
"codes": Mock(spec=EmailRegistrationCodeGenerator),
|
||||
"notifications": Mock(spec=EmailRegistrationNotificationGateway),
|
||||
"send_limits": Mock(spec=EmailRegistrationSendLimiter),
|
||||
"security": Mock(spec=EmailRegistrationSecurityGateway),
|
||||
"account_policy": Mock(spec=AccountRegistrationPolicyGateway),
|
||||
"registration": Mock(spec=AccountRegistrationGateway),
|
||||
}
|
||||
service = AccountEmailRegistrationService(
|
||||
accounts=dependencies["accounts"],
|
||||
tokens=dependencies["tokens"],
|
||||
codes=dependencies["codes"],
|
||||
notifications=dependencies["notifications"],
|
||||
send_limits=dependencies["send_limits"],
|
||||
security=dependencies["security"],
|
||||
account_policy=dependencies["account_policy"],
|
||||
registration=dependencies["registration"],
|
||||
)
|
||||
dependencies["accounts"].find_by_email.return_value = None
|
||||
dependencies["codes"].generate.return_value = "123456"
|
||||
dependencies["tokens"].issue.return_value = "token-1"
|
||||
dependencies["send_limits"].is_limited.return_value = False
|
||||
dependencies["security"].is_ip_limited.return_value = False
|
||||
dependencies["security"].is_verification_limited.return_value = False
|
||||
dependencies["account_policy"].get_freeze_type.return_value = None
|
||||
return service, dependencies
|
||||
|
||||
|
||||
def test_send_code_uses_case_fallback_account_and_existing_account_notification() -> None:
|
||||
service, dependencies = _service()
|
||||
dependencies["accounts"].find_by_email.return_value = _account(email="Stored@Example.com")
|
||||
|
||||
token = service.send_code(
|
||||
remote_ip="127.0.0.1",
|
||||
requested_email="Stored@Example.com",
|
||||
requested_language="zh-Hans",
|
||||
)
|
||||
|
||||
assert token == "token-1"
|
||||
dependencies["accounts"].find_by_email.assert_called_once_with("Stored@Example.com")
|
||||
dependencies["tokens"].issue.assert_called_once_with(
|
||||
AccountEmailRegistrationToken(email="Stored@Example.com", code="123456")
|
||||
)
|
||||
dependencies["notifications"].send_account_exists.assert_called_once_with(
|
||||
email="Stored@Example.com",
|
||||
account_name="Stored Account",
|
||||
language="zh-Hans",
|
||||
)
|
||||
dependencies["send_limits"].record.assert_called_once_with("Stored@Example.com")
|
||||
|
||||
|
||||
def test_send_code_normalizes_new_account_email_and_language() -> None:
|
||||
service, dependencies = _service()
|
||||
|
||||
service.send_code(
|
||||
remote_ip="127.0.0.1",
|
||||
requested_email="New@Example.com",
|
||||
requested_language="unsupported",
|
||||
)
|
||||
|
||||
dependencies["notifications"].send_code.assert_called_once_with(
|
||||
email="new@example.com",
|
||||
code="123456",
|
||||
language="en-US",
|
||||
)
|
||||
|
||||
|
||||
def test_send_code_rejects_suspended_domain_before_account_lookup() -> None:
|
||||
service, dependencies = _service()
|
||||
dependencies["account_policy"].get_freeze_type.return_value = "email_domain_suspended"
|
||||
|
||||
with pytest.raises(AccountEmailDomainSuspendedError):
|
||||
service.send_code(
|
||||
remote_ip="127.0.0.1",
|
||||
requested_email="user@suspended.example",
|
||||
requested_language=None,
|
||||
)
|
||||
|
||||
dependencies["accounts"].find_by_email.assert_not_called()
|
||||
|
||||
|
||||
def test_verify_code_rotates_token_into_register_phase() -> None:
|
||||
service, dependencies = _service()
|
||||
dependencies["tokens"].get.return_value = AccountEmailRegistrationToken(
|
||||
email="User@Example.com",
|
||||
code="123456",
|
||||
)
|
||||
dependencies["tokens"].issue.return_value = "verified-token"
|
||||
|
||||
verification = service.verify_code(
|
||||
email="USER@example.com",
|
||||
code="123456",
|
||||
token="pending-token",
|
||||
)
|
||||
|
||||
assert verification.email == "user@example.com"
|
||||
assert verification.token == "verified-token"
|
||||
dependencies["tokens"].revoke.assert_called_once_with("pending-token")
|
||||
dependencies["tokens"].issue.assert_called_once_with(
|
||||
AccountEmailRegistrationToken(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
phase=AccountEmailRegistrationPhase.REGISTER,
|
||||
)
|
||||
)
|
||||
dependencies["security"].reset_verification_failures.assert_called_once_with("user@example.com")
|
||||
|
||||
|
||||
def test_verify_code_records_failure_without_consuming_token() -> None:
|
||||
service, dependencies = _service()
|
||||
dependencies["tokens"].get.return_value = AccountEmailRegistrationToken(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidEmailRegistrationCodeError):
|
||||
service.verify_code(email="user@example.com", code="wrong", token="pending-token")
|
||||
|
||||
dependencies["security"].record_verification_failure.assert_called_once_with("user@example.com")
|
||||
dependencies["tokens"].revoke.assert_not_called()
|
||||
|
||||
|
||||
def test_register_creates_account_and_logs_it_in() -> None:
|
||||
service, dependencies = _service()
|
||||
dependencies["tokens"].get.return_value = AccountEmailRegistrationToken(
|
||||
email="New@Example.com",
|
||||
code="123456",
|
||||
phase=AccountEmailRegistrationPhase.REGISTER,
|
||||
)
|
||||
dependencies["registration"].create.return_value = "account-1"
|
||||
expected_tokens = AccountSessionTokens(access_token="access", refresh_token="refresh", csrf_token="csrf")
|
||||
dependencies["registration"].login.return_value = expected_tokens
|
||||
|
||||
tokens = service.register(
|
||||
remote_ip="127.0.0.1",
|
||||
token="verified-token",
|
||||
new_password="ValidPass123!",
|
||||
password_confirm="ValidPass123!",
|
||||
language="zh-Hans",
|
||||
timezone="Asia/Shanghai",
|
||||
)
|
||||
|
||||
assert tokens == expected_tokens
|
||||
dependencies["tokens"].revoke.assert_called_once_with("verified-token")
|
||||
dependencies["accounts"].find_by_email.assert_called_once_with("New@Example.com")
|
||||
dependencies["registration"].create.assert_called_once_with(
|
||||
email="new@example.com",
|
||||
password="ValidPass123!",
|
||||
interface_language="zh-Hans",
|
||||
timezone="Asia/Shanghai",
|
||||
ip_address="127.0.0.1",
|
||||
)
|
||||
dependencies["registration"].login.assert_called_once_with("account-1", ip_address="127.0.0.1")
|
||||
dependencies["security"].reset_login_failures.assert_called_once_with("new@example.com")
|
||||
|
||||
|
||||
def test_register_rejects_password_mismatch_before_reading_token() -> None:
|
||||
service, dependencies = _service()
|
||||
|
||||
with pytest.raises(EmailRegistrationPasswordMismatchError):
|
||||
service.register(
|
||||
remote_ip="127.0.0.1",
|
||||
token="verified-token",
|
||||
new_password="ValidPass123!",
|
||||
password_confirm="DifferentPass123!",
|
||||
language=None,
|
||||
timezone=None,
|
||||
)
|
||||
|
||||
dependencies["tokens"].get.assert_not_called()
|
||||
|
||||
|
||||
def test_register_requires_verified_registration_phase() -> None:
|
||||
service, dependencies = _service()
|
||||
dependencies["tokens"].get.return_value = AccountEmailRegistrationToken(
|
||||
email="new@example.com",
|
||||
code="123456",
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidEmailRegistrationTokenError):
|
||||
service.register(
|
||||
remote_ip="127.0.0.1",
|
||||
token="pending-token",
|
||||
new_password="ValidPass123!",
|
||||
password_confirm="ValidPass123!",
|
||||
language=None,
|
||||
timezone=None,
|
||||
)
|
||||
|
||||
dependencies["tokens"].revoke.assert_not_called()
|
||||
|
||||
|
||||
def test_register_consumes_token_before_rejecting_existing_account() -> None:
|
||||
service, dependencies = _service()
|
||||
dependencies["tokens"].get.return_value = AccountEmailRegistrationToken(
|
||||
email="existing@example.com",
|
||||
code="123456",
|
||||
phase=AccountEmailRegistrationPhase.REGISTER,
|
||||
)
|
||||
dependencies["accounts"].find_by_email.return_value = _account(email="existing@example.com")
|
||||
|
||||
with pytest.raises(AccountEmailAlreadyInUseError):
|
||||
service.register(
|
||||
remote_ip="127.0.0.1",
|
||||
token="verified-token",
|
||||
new_password="ValidPass123!",
|
||||
password_confirm="ValidPass123!",
|
||||
language=None,
|
||||
timezone=None,
|
||||
)
|
||||
|
||||
dependencies["tokens"].revoke.assert_called_once_with("verified-token")
|
||||
dependencies["registration"].create.assert_not_called()
|
||||
@ -27,7 +27,7 @@ from services.account_service import (
|
||||
RegisterService,
|
||||
TenantService,
|
||||
)
|
||||
from services.enterprise.rbac_service import MembersInRole, Paginated
|
||||
from services.enterprise.rbac_service import MemberRolesResponse, MembersInRole, Paginated, RBACRole
|
||||
from services.errors.account import (
|
||||
AccountAlreadyInTenantError,
|
||||
AccountEmailAlreadyInUseError,
|
||||
@ -798,6 +798,14 @@ class TestTenantService:
|
||||
sqlite_session.add(tenant_account_join)
|
||||
return tenant_account_join
|
||||
|
||||
def _db_role_of(self, sqlite_session: Session, tenant: Tenant, account_id: str) -> str | None:
|
||||
return sqlite_session.scalar(
|
||||
select(TenantAccountJoin.role).where(
|
||||
TenantAccountJoin.tenant_id == tenant.id,
|
||||
TenantAccountJoin.account_id == account_id,
|
||||
)
|
||||
)
|
||||
|
||||
def test_iter_member_account_id_batches_uses_offset_limit(self, sqlite_session: Session) -> None:
|
||||
tenant_id = "00000000-0000-0000-0000-000000000001"
|
||||
account_ids = [
|
||||
@ -1332,6 +1340,69 @@ class TestTenantService:
|
||||
assert persisted_target_join is not None
|
||||
assert persisted_target_join.role == TenantAccountRole.ADMIN
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("outgoing_owner_role_tags", "expected_demoted_role_ids"),
|
||||
[(["owner", "editor"], ["editor-role-id"]), (["owner"], ["no-access-role-id"])],
|
||||
)
|
||||
def test_update_member_role_to_owner_rbac_enabled(
|
||||
self,
|
||||
sqlite_session: Session,
|
||||
outgoing_owner_role_tags: list[str],
|
||||
expected_demoted_role_ids: list[str],
|
||||
config_overrides: Callable[..., None],
|
||||
) -> None:
|
||||
config_overrides(RBAC_ENABLED=True)
|
||||
tenant = Tenant(name="Test Workspace")
|
||||
sqlite_session.add(tenant)
|
||||
sqlite_session.flush()
|
||||
|
||||
operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-1")
|
||||
candidate = TestAccountAssociatedDataFactory.create_account_mock(account_id="candidate-1")
|
||||
self._add_tenant_account_join(sqlite_session, tenant, operator.id, TenantAccountRole.EDITOR)
|
||||
self._add_tenant_account_join(sqlite_session, tenant, candidate.id, TenantAccountRole.EDITOR)
|
||||
self._add_tenant_account_join(sqlite_session, tenant, "stale-db-owner", TenantAccountRole.OWNER)
|
||||
sqlite_session.commit()
|
||||
|
||||
outgoing_owner_roles = MemberRolesResponse(
|
||||
account_id="real-rbac-owner",
|
||||
roles=[
|
||||
RBACRole(id=f"{tag}-role-id", type="workspace", name=tag, role_tag=tag)
|
||||
for tag in outgoing_owner_role_tags
|
||||
],
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"services.account_service.AccountService.get_workspace_permission_keys",
|
||||
return_value={"workspace.role.manage"},
|
||||
),
|
||||
patch(
|
||||
"services.account_service.AccountService.get_rbac_workspace_owner_account_id",
|
||||
return_value="real-rbac-owner",
|
||||
),
|
||||
patch(
|
||||
"services.account_service.AccountService._resolve_legacy_role_id",
|
||||
side_effect=lambda *, role, **_kwargs: f"{role.value}-role-id",
|
||||
),
|
||||
patch(
|
||||
"services.account_service.AccountService._resolve_role_id_by_tag",
|
||||
return_value="no-access-role-id",
|
||||
),
|
||||
patch("services.account_service.RBACService.MemberRoles.get", return_value=outgoing_owner_roles),
|
||||
patch("services.account_service.RBACService.MemberRoles.replace") as mock_replace,
|
||||
):
|
||||
TenantService.update_member_role(tenant, candidate, "owner", operator, session=sqlite_session)
|
||||
|
||||
mock_replace.assert_any_call(
|
||||
tenant_id=tenant.id,
|
||||
account_id=operator.id,
|
||||
member_account_id="real-rbac-owner",
|
||||
role_ids=expected_demoted_role_ids,
|
||||
session=sqlite_session,
|
||||
)
|
||||
assert self._db_role_of(sqlite_session, tenant, "stale-db-owner") == TenantAccountRole.NORMAL
|
||||
assert self._db_role_of(sqlite_session, tenant, candidate.id) == TenantAccountRole.OWNER
|
||||
|
||||
def test_create_owner_tenant_rbac_enabled_assigns_owner_role(
|
||||
self,
|
||||
sqlite_session: Session,
|
||||
|
||||
@ -21,13 +21,18 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import services.app_generate_service as ags_module
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from enums import DeploymentEdition, QuotaType
|
||||
from models.model import AppMode
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.errors.app import WorkflowIdFormatError, WorkflowNotFoundError
|
||||
from services.errors.app import (
|
||||
TriggerWorkflowServiceModeUnavailableError,
|
||||
WorkflowIdFormatError,
|
||||
WorkflowNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -79,10 +84,24 @@ def _make_user() -> MagicMock:
|
||||
return user
|
||||
|
||||
|
||||
def _make_workflow(*, workflow_id: str = "workflow-id", created_by: str = "owner-id") -> MagicMock:
|
||||
class _RealSessionTest:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bind_unbound_session(self, unbound_session: Session) -> None:
|
||||
self.session = unbound_session
|
||||
|
||||
|
||||
def _make_workflow(
|
||||
*,
|
||||
workflow_id: str = "workflow-id",
|
||||
created_by: str = "owner-id",
|
||||
node_types: tuple[str, ...] = (),
|
||||
) -> MagicMock:
|
||||
workflow = MagicMock()
|
||||
workflow.id = workflow_id
|
||||
workflow.created_by = created_by
|
||||
workflow.walk_nodes.return_value = [
|
||||
(f"node-{index}", {"type": node_type}) for index, node_type in enumerate(node_types)
|
||||
]
|
||||
return workflow
|
||||
|
||||
|
||||
@ -251,7 +270,7 @@ class TestGetMaxActiveRequests:
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate – every AppMode branch
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestGenerate:
|
||||
class TestGenerate(_RealSessionTest):
|
||||
"""Tests for AppGenerateService.generate covering each mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@ -280,7 +299,7 @@ class TestGenerate:
|
||||
args={"inputs": {}},
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=False,
|
||||
session=MagicMock(),
|
||||
session=self.session,
|
||||
)
|
||||
assert result == {"result": "ok"}
|
||||
gen_spy.assert_called_once()
|
||||
@ -301,7 +320,7 @@ class TestGenerate:
|
||||
args={"inputs": {}},
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=False,
|
||||
session=MagicMock(),
|
||||
session=self.session,
|
||||
)
|
||||
assert result == {"result": "agent"}
|
||||
gen_spy.assert_called_once()
|
||||
@ -317,7 +336,7 @@ class TestGenerate:
|
||||
side_effect=lambda x: x,
|
||||
)
|
||||
app = _make_app(AppMode.CHAT, is_agent=True)
|
||||
session = MagicMock()
|
||||
session = self.session
|
||||
result = AppGenerateService.generate(
|
||||
app_model=app,
|
||||
user=_make_user(),
|
||||
@ -340,7 +359,7 @@ class TestGenerate:
|
||||
"services.app_generate_service.AgentAppGenerator.convert_to_event_stream",
|
||||
side_effect=lambda x: x,
|
||||
)
|
||||
session = MagicMock()
|
||||
session = self.session
|
||||
|
||||
result = AppGenerateService.generate(
|
||||
app_model=_make_app(AppMode.AGENT),
|
||||
@ -371,7 +390,7 @@ class TestGenerate:
|
||||
args={"inputs": {}},
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=False,
|
||||
session=MagicMock(),
|
||||
session=self.session,
|
||||
)
|
||||
assert result == {"result": "chat"}
|
||||
gen_spy.assert_called_once()
|
||||
@ -391,7 +410,7 @@ class TestGenerate:
|
||||
side_effect=lambda x: x,
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
session = self.session
|
||||
result = AppGenerateService.generate(
|
||||
app_model=_make_app(AppMode.ADVANCED_CHAT),
|
||||
user=_make_user(),
|
||||
@ -430,7 +449,7 @@ class TestGenerate:
|
||||
args={"workflow_id": None, "query": "hi", "inputs": {}},
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=True,
|
||||
session=MagicMock(),
|
||||
session=self.session,
|
||||
)
|
||||
# In streaming mode it should go through retrieve_events, not generate
|
||||
gen_instance.retrieve_events.assert_called_once()
|
||||
@ -453,7 +472,7 @@ class TestGenerate:
|
||||
side_effect=lambda x: x,
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
session = self.session
|
||||
result = AppGenerateService.generate(
|
||||
app_model=_make_app(AppMode.WORKFLOW),
|
||||
user=_make_user(),
|
||||
@ -467,6 +486,84 @@ class TestGenerate:
|
||||
assert call_kwargs.get("pause_state_config") is not None
|
||||
assert call_kwargs["pause_state_config"].state_owner_user_id == "owner-id"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invoke_from",
|
||||
[InvokeFrom.OPENAPI, InvokeFrom.SERVICE_API, InvokeFrom.WEB_APP],
|
||||
)
|
||||
@pytest.mark.parametrize("node_type", ["trigger-plugin", "trigger-schedule", "trigger-webhook"])
|
||||
def test_trigger_workflow_rejects_manual_service_surfaces(
|
||||
self,
|
||||
invoke_from: InvokeFrom,
|
||||
node_type: str,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
workflow = _make_workflow(node_types=(node_type,))
|
||||
mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow)
|
||||
generate = mocker.patch("services.app_generate_service.WorkflowAppGenerator.generate")
|
||||
|
||||
with pytest.raises(TriggerWorkflowServiceModeUnavailableError):
|
||||
AppGenerateService.generate(
|
||||
app_model=_make_app(AppMode.WORKFLOW),
|
||||
user=_make_user(),
|
||||
args={"inputs": {}},
|
||||
invoke_from=invoke_from,
|
||||
streaming=False,
|
||||
session=MagicMock(),
|
||||
)
|
||||
|
||||
generate.assert_not_called()
|
||||
|
||||
def test_trigger_workflow_allows_trigger_execution(self, mocker: MockerFixture) -> None:
|
||||
workflow = _make_workflow(node_types=("trigger-webhook",))
|
||||
mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow)
|
||||
generate = mocker.patch(
|
||||
"services.app_generate_service.WorkflowAppGenerator.generate",
|
||||
return_value={"result": "trigger"},
|
||||
)
|
||||
mocker.patch(
|
||||
"services.app_generate_service.WorkflowAppGenerator.convert_to_event_stream",
|
||||
side_effect=lambda value: value,
|
||||
)
|
||||
|
||||
result = AppGenerateService.generate(
|
||||
app_model=_make_app(AppMode.WORKFLOW),
|
||||
user=_make_user(),
|
||||
args={"inputs": {}},
|
||||
invoke_from=InvokeFrom.TRIGGER,
|
||||
streaming=False,
|
||||
session=MagicMock(),
|
||||
)
|
||||
|
||||
assert result == {"result": "trigger"}
|
||||
generate.assert_called_once()
|
||||
|
||||
def test_specific_start_workflow_version_remains_runnable(self, mocker: MockerFixture) -> None:
|
||||
workflow_id = str(uuid.uuid4())
|
||||
workflow = _make_workflow(workflow_id=workflow_id, node_types=("start",))
|
||||
get_workflow = mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow)
|
||||
mocker.patch(
|
||||
"services.app_generate_service.WorkflowAppGenerator.generate",
|
||||
return_value={"result": "version"},
|
||||
)
|
||||
mocker.patch(
|
||||
"services.app_generate_service.WorkflowAppGenerator.convert_to_event_stream",
|
||||
side_effect=lambda value: value,
|
||||
)
|
||||
app = _make_app(AppMode.WORKFLOW)
|
||||
session = MagicMock()
|
||||
|
||||
result = AppGenerateService.generate(
|
||||
app_model=app,
|
||||
user=_make_user(),
|
||||
args={"inputs": {}, "workflow_id": workflow_id},
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=False,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result == {"result": "version"}
|
||||
get_workflow.assert_called_once_with(app, InvokeFrom.SERVICE_API, workflow_id, session=session)
|
||||
|
||||
# -- WORKFLOW streaming -------------------------------------------------
|
||||
def test_workflow_streaming(self, mocker: MockerFixture, config_overrides: Callable[..., None]):
|
||||
config_overrides(PUBSUB_REDIS_CHANNEL_TYPE="streams")
|
||||
@ -492,7 +589,7 @@ class TestGenerate:
|
||||
args={"inputs": {}},
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=True,
|
||||
session=MagicMock(),
|
||||
session=self.session,
|
||||
)
|
||||
retrieve_spy.assert_called_once()
|
||||
# Dispatch is gated on subscribe; simulate the SSE layer entering the
|
||||
@ -511,14 +608,14 @@ class TestGenerate:
|
||||
args={},
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=False,
|
||||
session=MagicMock(),
|
||||
session=self.session,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate – billing / quota
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestGenerateBilling:
|
||||
class TestGenerateBilling(_RealSessionTest):
|
||||
@pytest.fixture(autouse=True)
|
||||
def _common(self, mocker: MockerFixture):
|
||||
mocker.patch("services.app_generate_service.RateLimit", _DummyRateLimit)
|
||||
@ -549,7 +646,7 @@ class TestGenerateBilling:
|
||||
args={"inputs": {}},
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=False,
|
||||
session=MagicMock(),
|
||||
session=self.session,
|
||||
)
|
||||
reserve_mock.assert_called_once_with(QuotaType.WORKFLOW, "tenant-id")
|
||||
quota_charge.commit.assert_called_once()
|
||||
@ -573,7 +670,7 @@ class TestGenerateBilling:
|
||||
args={"inputs": {}},
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=False,
|
||||
session=MagicMock(),
|
||||
session=self.session,
|
||||
)
|
||||
|
||||
def test_exception_refunds_quota_and_exits_rate_limit(
|
||||
@ -601,7 +698,7 @@ class TestGenerateBilling:
|
||||
args={"inputs": {}},
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=False,
|
||||
session=MagicMock(),
|
||||
session=self.session,
|
||||
)
|
||||
quota_charge.refund.assert_called_once()
|
||||
|
||||
@ -633,7 +730,7 @@ class TestGenerateBilling:
|
||||
args={"inputs": {}},
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=False,
|
||||
session=MagicMock(),
|
||||
session=self.session,
|
||||
)
|
||||
# exit is called in finally block for non-streaming
|
||||
assert exit_calls == ["dummy-request-id"]
|
||||
@ -664,7 +761,7 @@ class TestGenerateBilling:
|
||||
args={"inputs": {}},
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=False,
|
||||
session=MagicMock(),
|
||||
session=self.session,
|
||||
)
|
||||
|
||||
quota_charge.refund.assert_called_once()
|
||||
@ -698,7 +795,7 @@ class TestGenerateBilling:
|
||||
args={"inputs": {}},
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=True,
|
||||
session=MagicMock(),
|
||||
session=self.session,
|
||||
)
|
||||
|
||||
quota_charge.refund.assert_called_once()
|
||||
@ -708,14 +805,16 @@ class TestGenerateBilling:
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_workflow
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestGetWorkflow:
|
||||
class TestGetWorkflow(_RealSessionTest):
|
||||
def test_debugger_fetches_draft(self, mocker: MockerFixture):
|
||||
draft_wf = _make_workflow()
|
||||
ws = MagicMock()
|
||||
ws.get_draft_workflow.return_value = draft_wf
|
||||
mocker.patch("services.app_generate_service.WorkflowService", return_value=ws)
|
||||
|
||||
result = AppGenerateService._get_workflow(_make_app(AppMode.WORKFLOW), InvokeFrom.DEBUGGER, session=MagicMock())
|
||||
result = AppGenerateService._get_workflow(
|
||||
_make_app(AppMode.WORKFLOW), InvokeFrom.DEBUGGER, session=self.session
|
||||
)
|
||||
assert result is draft_wf
|
||||
ws.get_draft_workflow.assert_called_once()
|
||||
|
||||
@ -725,7 +824,7 @@ class TestGetWorkflow:
|
||||
mocker.patch("services.app_generate_service.WorkflowService", return_value=ws)
|
||||
|
||||
with pytest.raises(ValueError, match="Workflow not initialized"):
|
||||
AppGenerateService._get_workflow(_make_app(AppMode.WORKFLOW), InvokeFrom.DEBUGGER, session=MagicMock())
|
||||
AppGenerateService._get_workflow(_make_app(AppMode.WORKFLOW), InvokeFrom.DEBUGGER, session=self.session)
|
||||
|
||||
def test_non_debugger_fetches_published(self, mocker: MockerFixture):
|
||||
pub_wf = _make_workflow()
|
||||
@ -734,7 +833,7 @@ class TestGetWorkflow:
|
||||
mocker.patch("services.app_generate_service.WorkflowService", return_value=ws)
|
||||
|
||||
result = AppGenerateService._get_workflow(
|
||||
_make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, session=MagicMock()
|
||||
_make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, session=self.session
|
||||
)
|
||||
assert result is pub_wf
|
||||
ws.get_published_workflow.assert_called_once()
|
||||
@ -745,7 +844,7 @@ class TestGetWorkflow:
|
||||
mocker.patch("services.app_generate_service.WorkflowService", return_value=ws)
|
||||
|
||||
with pytest.raises(ValueError, match="Workflow not published"):
|
||||
AppGenerateService._get_workflow(_make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, session=MagicMock())
|
||||
AppGenerateService._get_workflow(_make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, session=self.session)
|
||||
|
||||
def test_specific_workflow_id_valid_uuid(self, mocker: MockerFixture):
|
||||
valid_uuid = str(uuid.uuid4())
|
||||
@ -758,7 +857,7 @@ class TestGetWorkflow:
|
||||
_make_app(AppMode.WORKFLOW),
|
||||
InvokeFrom.SERVICE_API,
|
||||
workflow_id=valid_uuid,
|
||||
session=MagicMock(),
|
||||
session=self.session,
|
||||
)
|
||||
assert result is specific_wf
|
||||
ws.get_published_workflow_by_id.assert_called_once()
|
||||
@ -772,7 +871,7 @@ class TestGetWorkflow:
|
||||
_make_app(AppMode.WORKFLOW),
|
||||
InvokeFrom.SERVICE_API,
|
||||
workflow_id="not-a-uuid",
|
||||
session=MagicMock(),
|
||||
session=self.session,
|
||||
)
|
||||
|
||||
def test_specific_workflow_id_not_found(self, mocker: MockerFixture):
|
||||
@ -786,14 +885,14 @@ class TestGetWorkflow:
|
||||
_make_app(AppMode.WORKFLOW),
|
||||
InvokeFrom.SERVICE_API,
|
||||
workflow_id=valid_uuid,
|
||||
session=MagicMock(),
|
||||
session=self.session,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate_single_iteration
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestGenerateSingleIteration:
|
||||
class TestGenerateSingleIteration(_RealSessionTest):
|
||||
def test_advanced_chat_mode(self, mocker: MockerFixture):
|
||||
workflow = _make_workflow()
|
||||
mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow)
|
||||
@ -806,7 +905,7 @@ class TestGenerateSingleIteration:
|
||||
return_value={"event": "iteration"},
|
||||
)
|
||||
app = _make_app(AppMode.ADVANCED_CHAT)
|
||||
session = MagicMock()
|
||||
session = self.session
|
||||
result = AppGenerateService.generate_single_iteration(
|
||||
app_model=app,
|
||||
user=_make_user(),
|
||||
@ -830,7 +929,7 @@ class TestGenerateSingleIteration:
|
||||
return_value={"event": "wf-iteration"},
|
||||
)
|
||||
app = _make_app(AppMode.WORKFLOW)
|
||||
session = MagicMock()
|
||||
session = self.session
|
||||
result = AppGenerateService.generate_single_iteration(
|
||||
app_model=app,
|
||||
user=_make_user(),
|
||||
@ -846,14 +945,14 @@ class TestGenerateSingleIteration:
|
||||
app = _make_app(AppMode.CHAT)
|
||||
with pytest.raises(ValueError, match="Invalid app mode"):
|
||||
AppGenerateService.generate_single_iteration(
|
||||
app_model=app, user=_make_user(), node_id="n1", args={}, session=MagicMock()
|
||||
app_model=app, user=_make_user(), node_id="n1", args={}, session=self.session
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate_single_loop
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestGenerateSingleLoop:
|
||||
class TestGenerateSingleLoop(_RealSessionTest):
|
||||
def test_advanced_chat_mode(self, mocker: MockerFixture):
|
||||
workflow = _make_workflow()
|
||||
mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow)
|
||||
@ -866,7 +965,7 @@ class TestGenerateSingleLoop:
|
||||
return_value={"event": "loop"},
|
||||
)
|
||||
app = _make_app(AppMode.ADVANCED_CHAT)
|
||||
session = MagicMock()
|
||||
session = self.session
|
||||
result = AppGenerateService.generate_single_loop(
|
||||
app_model=app,
|
||||
user=_make_user(),
|
||||
@ -890,7 +989,7 @@ class TestGenerateSingleLoop:
|
||||
return_value={"event": "wf-loop"},
|
||||
)
|
||||
app = _make_app(AppMode.WORKFLOW)
|
||||
session = MagicMock()
|
||||
session = self.session
|
||||
result = AppGenerateService.generate_single_loop(
|
||||
app_model=app,
|
||||
user=_make_user(),
|
||||
@ -906,20 +1005,20 @@ class TestGenerateSingleLoop:
|
||||
app = _make_app(AppMode.COMPLETION)
|
||||
with pytest.raises(ValueError, match="Invalid app mode"):
|
||||
AppGenerateService.generate_single_loop(
|
||||
app_model=app, user=_make_user(), node_id="n1", args=MagicMock(), session=MagicMock()
|
||||
app_model=app, user=_make_user(), node_id="n1", args=MagicMock(), session=self.session
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate_more_like_this
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestGenerateMoreLikeThis:
|
||||
class TestGenerateMoreLikeThis(_RealSessionTest):
|
||||
def test_delegates_to_completion_generator(self, mocker: MockerFixture):
|
||||
gen_spy = mocker.patch(
|
||||
"services.app_generate_service.CompletionAppGenerator.generate_more_like_this",
|
||||
return_value={"result": "similar"},
|
||||
)
|
||||
session = MagicMock()
|
||||
session = self.session
|
||||
result = AppGenerateService.generate_more_like_this(
|
||||
app_model=_make_app(AppMode.COMPLETION),
|
||||
user=_make_user(),
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import types
|
||||
from unittest.mock import Mock, create_autospec
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from redis.exceptions import LockNotOwnedError
|
||||
@ -203,19 +203,48 @@ def test_add_segment_ignores_lock_not_owned(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Account, Tenant, Dataset, Document, DocumentSegment)], indirect=True)
|
||||
def test_multi_create_segment_ignores_lock_not_owned(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
fake_current_user,
|
||||
fake_lock,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
# Arrange
|
||||
dataset = create_autospec(Dataset, instance=True)
|
||||
dataset.id = "ds-1"
|
||||
dataset.tenant_id = fake_current_user.current_tenant_id
|
||||
dataset.indexing_technique = IndexTechniqueType.ECONOMY # again, skip high_quality path
|
||||
dataset = Dataset(
|
||||
id=DATASET_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
name="Test Dataset",
|
||||
description="",
|
||||
created_by=USER_ID,
|
||||
indexing_technique=IndexTechniqueType.ECONOMY,
|
||||
)
|
||||
document = Document(
|
||||
id=DOCUMENT_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
dataset_id=DATASET_ID,
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
data_source_info="{}",
|
||||
batch="batch-1",
|
||||
name="Test Document",
|
||||
created_from="web",
|
||||
created_by=USER_ID,
|
||||
word_count=0,
|
||||
doc_form=IndexStructureType.QA_INDEX,
|
||||
)
|
||||
sqlite_session.add_all([fake_current_user._current_tenant, fake_current_user, dataset, document])
|
||||
sqlite_session.commit()
|
||||
|
||||
document = create_autospec(Document, instance=True)
|
||||
document.id = "doc-1"
|
||||
document.dataset_id = dataset.id
|
||||
document.word_count = 0
|
||||
document.doc_form = IndexStructureType.QA_INDEX
|
||||
result = SegmentService.multi_create_segment(
|
||||
segments=[{"content": "question", "answer": "answer", "keywords": ["key"]}],
|
||||
document=document,
|
||||
dataset=dataset,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert not sqlite_session.in_transaction()
|
||||
assert sqlite_session.scalar(select(func.count(DocumentSegment.id))) == 0
|
||||
sqlite_session.refresh(document)
|
||||
assert document.word_count == 0
|
||||
|
||||
63
api/tests/unit_tests/services/test_notification_gateway.py
Normal file
63
api/tests/unit_tests/services/test_notification_gateway.py
Normal file
@ -0,0 +1,63 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from services.entities.notification_entities import NotificationContent
|
||||
from services.notification_gateway import BillingNotificationGateway
|
||||
|
||||
|
||||
def test_get_active_maps_billing_proto_json_contract() -> None:
|
||||
payload = {
|
||||
"shouldShow": True,
|
||||
"notifications": [
|
||||
{
|
||||
"notificationId": "notification-1",
|
||||
"frequency": "once",
|
||||
"contents": {
|
||||
"en-US": {
|
||||
"lang": "en-US",
|
||||
"title": "Title",
|
||||
"subtitle": "Subtitle",
|
||||
"body": "Body",
|
||||
"titlePicUrl": "title.png",
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with patch("services.notification_gateway.BillingService.get_account_notification", return_value=payload):
|
||||
result = BillingNotificationGateway().get_active("account-1")
|
||||
|
||||
assert result.should_show is True
|
||||
assert result.notifications[0].notification_id == "notification-1"
|
||||
assert result.notifications[0].contents["en-US"].title_pic_url == "title.png"
|
||||
|
||||
|
||||
def test_get_active_omits_empty_localized_content_so_service_can_fall_back() -> None:
|
||||
empty_localized_content: dict[str, str] = {}
|
||||
payload = {
|
||||
"shouldShow": True,
|
||||
"notifications": [
|
||||
{
|
||||
"notificationId": "notification-1",
|
||||
"frequency": "once",
|
||||
"contents": {
|
||||
"zh-Hans": empty_localized_content,
|
||||
"en-US": {"lang": "en-US", "title": "Title"},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with patch("services.notification_gateway.BillingService.get_account_notification", return_value=payload):
|
||||
result = BillingNotificationGateway().get_active("account-1")
|
||||
|
||||
assert result.notifications[0].contents == {
|
||||
"en-US": NotificationContent("en-US", "Title", "", "", ""),
|
||||
}
|
||||
|
||||
|
||||
def test_dismiss_delegates_to_billing_service() -> None:
|
||||
with patch("services.notification_gateway.BillingService.dismiss_notification") as dismiss:
|
||||
BillingNotificationGateway().dismiss("notification-1", "account-1")
|
||||
|
||||
dismiss.assert_called_once_with(notification_id="notification-1", account_id="account-1")
|
||||
138
api/tests/unit_tests/services/test_notification_service.py
Normal file
138
api/tests/unit_tests/services/test_notification_service.py
Normal file
@ -0,0 +1,138 @@
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from machinery.context import RequestContext
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.account_entities import AccountSnapshot
|
||||
from services.entities.notification_entities import (
|
||||
AccountNotification,
|
||||
AccountNotificationBatch,
|
||||
NotificationContent,
|
||||
NotificationItem,
|
||||
NotificationResult,
|
||||
)
|
||||
from services.notification_service import NotificationService
|
||||
|
||||
|
||||
def _context() -> RequestContext:
|
||||
return RequestContext(
|
||||
request_id="request-1",
|
||||
trace_id="trace-1",
|
||||
account_id="account-1",
|
||||
active_workspace_id="workspace-1",
|
||||
)
|
||||
|
||||
|
||||
class NotificationGatewayStub:
|
||||
def __init__(self, batch: AccountNotificationBatch) -> None:
|
||||
self.batch = batch
|
||||
self.get_account_ids: list[str] = []
|
||||
self.dismissals: list[tuple[str, str]] = []
|
||||
|
||||
def get_active(self, account_id: str) -> AccountNotificationBatch:
|
||||
self.get_account_ids.append(account_id)
|
||||
return self.batch
|
||||
|
||||
def dismiss(self, notification_id: str, account_id: str) -> None:
|
||||
self.dismissals.append((notification_id, account_id))
|
||||
|
||||
|
||||
def _account(language: str | None = "zh-Hans") -> AccountSnapshot:
|
||||
return AccountSnapshot(
|
||||
id="account-1",
|
||||
name="Account",
|
||||
email="account@example.com",
|
||||
avatar=None,
|
||||
is_password_set=False,
|
||||
interface_language=language,
|
||||
interface_theme="light",
|
||||
timezone="UTC",
|
||||
last_login_at=None,
|
||||
last_login_ip=None,
|
||||
status="active",
|
||||
initialized_at=None,
|
||||
created_at=datetime(2026, 1, 1),
|
||||
)
|
||||
|
||||
|
||||
def _accounts(account: AccountSnapshot | None) -> Mock:
|
||||
accounts = Mock(spec=AccountRepository)
|
||||
accounts.get.return_value = account
|
||||
return accounts
|
||||
|
||||
|
||||
def _notification(contents: dict[str, NotificationContent]) -> AccountNotification:
|
||||
return AccountNotification(
|
||||
notification_id="notification-1",
|
||||
frequency="once",
|
||||
contents=contents,
|
||||
)
|
||||
|
||||
|
||||
def test_get_active_localizes_notification_for_account_language() -> None:
|
||||
chinese = NotificationContent("zh-Hans", "标题", "副标题", "正文", "zh.png")
|
||||
english = NotificationContent("en-US", "Title", "Subtitle", "Body", "en.png")
|
||||
gateway = NotificationGatewayStub(
|
||||
AccountNotificationBatch(True, (_notification({"zh-Hans": chinese, "en-US": english}),))
|
||||
)
|
||||
service = NotificationService(accounts=_accounts(_account()), notifications=gateway)
|
||||
|
||||
result = service.get_active(_context())
|
||||
|
||||
assert result == NotificationResult(
|
||||
should_show=True,
|
||||
notifications=(NotificationItem("notification-1", "once", "zh-Hans", "标题", "副标题", "正文", "zh.png"),),
|
||||
)
|
||||
assert gateway.get_account_ids == ["account-1"]
|
||||
|
||||
|
||||
def test_get_active_falls_back_to_english() -> None:
|
||||
english = NotificationContent("en-US", "Title", "Subtitle", "Body", "en.png")
|
||||
gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({"en-US": english}),)))
|
||||
service = NotificationService(accounts=_accounts(_account("fr-FR")), notifications=gateway)
|
||||
|
||||
result = service.get_active(_context())
|
||||
|
||||
assert result.notifications[0].lang == "en-US"
|
||||
assert result.notifications[0].title == "Title"
|
||||
|
||||
|
||||
def test_get_active_skips_account_query_when_gateway_says_not_to_show() -> None:
|
||||
accounts = _accounts(None)
|
||||
service = NotificationService(
|
||||
accounts=accounts,
|
||||
notifications=NotificationGatewayStub(AccountNotificationBatch(False, ())),
|
||||
)
|
||||
|
||||
result = service.get_active(_context())
|
||||
|
||||
assert result == NotificationResult(False, ())
|
||||
accounts.get.assert_not_called()
|
||||
|
||||
|
||||
def test_get_active_uses_empty_content_when_notification_has_no_translations() -> None:
|
||||
gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({}),)))
|
||||
service = NotificationService(accounts=_accounts(_account(None)), notifications=gateway)
|
||||
|
||||
result = service.get_active(_context())
|
||||
|
||||
assert result.notifications == (NotificationItem("notification-1", "once", "en-US", "", "", "", ""),)
|
||||
|
||||
|
||||
def test_get_active_rejects_unknown_admitted_account() -> None:
|
||||
gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({}),)))
|
||||
service = NotificationService(accounts=_accounts(None), notifications=gateway)
|
||||
|
||||
with pytest.raises(RuntimeError, match="unknown account"):
|
||||
service.get_active(_context())
|
||||
|
||||
|
||||
def test_dismiss_delegates_identifiers_to_gateway() -> None:
|
||||
gateway = NotificationGatewayStub(AccountNotificationBatch(False, ()))
|
||||
service = NotificationService(accounts=_accounts(_account()), notifications=gateway)
|
||||
|
||||
service.dismiss(_context(), "notification-1")
|
||||
|
||||
assert gateway.dismissals == [("notification-1", "account-1")]
|
||||
@ -1,230 +1,213 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from enums import DeploymentEdition
|
||||
from models.account import Account, AccountStatus
|
||||
from models.onboarding import AccountStepByStepTourState
|
||||
from machinery.context import RequestContext
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.account_entities import AccountSnapshot
|
||||
from services.entities.onboarding_entities import StepByStepTourPatch, StepByStepTourResult, StepByStepTourState
|
||||
from services.step_by_step_tour_service import StepByStepTourService
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
|
||||
def _account(*, initialized_at: datetime | None = None, created_at: datetime | None = None) -> Account:
|
||||
account = Account(name="User", email="user@example.com", status=AccountStatus.ACTIVE)
|
||||
account.id = "account-1"
|
||||
account.initialized_at = initialized_at
|
||||
account.created_at = created_at or datetime(2026, 6, 28)
|
||||
return account
|
||||
|
||||
|
||||
def _state() -> AccountStepByStepTourState:
|
||||
state = AccountStepByStepTourState(account_id="account-1")
|
||||
state.updated_at = datetime(2026, 6, 28, tzinfo=UTC)
|
||||
return state
|
||||
|
||||
|
||||
def _persist_state(session: Session, state: AccountStepByStepTourState) -> None:
|
||||
session.add(state)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _load_state(session: Session) -> AccountStepByStepTourState | None:
|
||||
return session.scalar(
|
||||
select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == "account-1")
|
||||
def _context(*, workspace_id: str | None = "workspace-1") -> RequestContext:
|
||||
return RequestContext(
|
||||
request_id="request-1",
|
||||
trace_id="trace-1",
|
||||
account_id="account-1",
|
||||
active_workspace_id=workspace_id,
|
||||
)
|
||||
|
||||
|
||||
def _set_tour_config(monkeypatch: pytest.MonkeyPatch, *, enabled: bool, rollout_started_at: datetime | None) -> None:
|
||||
apply_config_overrides(
|
||||
monkeypatch,
|
||||
ENABLE_STEP_BY_STEP_TOUR=enabled,
|
||||
STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT=rollout_started_at,
|
||||
class StateRepositoryStub:
|
||||
def __init__(self, state: StepByStepTourState | None = None) -> None:
|
||||
self.state = state
|
||||
self.get_account_ids: list[str] = []
|
||||
self.initialize_calls: list[tuple[str, str]] = []
|
||||
self.mutation_account_ids: list[str] = []
|
||||
|
||||
def get(self, account_id: str) -> StepByStepTourState | None:
|
||||
self.get_account_ids.append(account_id)
|
||||
return self.state
|
||||
|
||||
def initialize(self, account_id: str, first_workspace_id: str) -> StepByStepTourState:
|
||||
self.initialize_calls.append((account_id, first_workspace_id))
|
||||
if self.state is None:
|
||||
self.state = StepByStepTourState(account_id=account_id, first_workspace_id=first_workspace_id)
|
||||
elif self.state.first_workspace_id is None:
|
||||
self.state = replace(self.state, first_workspace_id=first_workspace_id)
|
||||
return self.state
|
||||
|
||||
def mutate(
|
||||
self,
|
||||
account_id: str,
|
||||
mutation: Callable[[StepByStepTourState], StepByStepTourState],
|
||||
) -> StepByStepTourState:
|
||||
self.mutation_account_ids.append(account_id)
|
||||
if self.state is None:
|
||||
self.state = StepByStepTourState(account_id=account_id)
|
||||
self.state = mutation(self.state)
|
||||
return self.state
|
||||
|
||||
|
||||
def _account(*, started_at: datetime = datetime(2026, 6, 28)) -> 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="active",
|
||||
initialized_at=started_at,
|
||||
created_at=started_at,
|
||||
)
|
||||
|
||||
|
||||
def test_get_state_creates_state_and_records_first_workspace_for_eligible_account(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
|
||||
def _accounts(account: AccountSnapshot | None) -> Mock:
|
||||
accounts = Mock(spec=AccountRepository)
|
||||
accounts.get.return_value = account
|
||||
return accounts
|
||||
|
||||
result = StepByStepTourService.get_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
session=sqlite_session,
|
||||
|
||||
def _service(
|
||||
*,
|
||||
states: StateRepositoryStub,
|
||||
account: AccountSnapshot | None = None,
|
||||
enabled: bool = True,
|
||||
rollout_started_at: datetime | None = datetime(2026, 6, 1),
|
||||
) -> StepByStepTourService:
|
||||
return StepByStepTourService(
|
||||
accounts=_accounts(account or _account()),
|
||||
states=states,
|
||||
enabled=enabled,
|
||||
rollout_started_at=rollout_started_at,
|
||||
)
|
||||
|
||||
assert result["first_workspace_id"] == "workspace-1"
|
||||
assert result["completed_task_ids"] == []
|
||||
with sqlite_session_factory() as observer:
|
||||
persisted = _load_state(observer)
|
||||
assert persisted is not None
|
||||
assert persisted.account_id == "account-1"
|
||||
assert persisted.first_workspace_id == "workspace-1"
|
||||
|
||||
def test_get_state_creates_state_and_records_first_workspace_for_eligible_account() -> None:
|
||||
states = StateRepositoryStub()
|
||||
|
||||
result = _service(states=states).get_state(_context())
|
||||
|
||||
assert result.first_workspace_id == "workspace-1"
|
||||
assert states.get_account_ids == []
|
||||
assert states.initialize_calls == [("account-1", "workspace-1")]
|
||||
assert states.mutation_account_ids == []
|
||||
|
||||
|
||||
def test_is_eligible_does_not_depend_on_cloud_edition(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
|
||||
apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY)
|
||||
def test_get_state_returns_existing_state_without_rewriting_first_workspace() -> None:
|
||||
state = StepByStepTourState(account_id="account-1", first_workspace_id="workspace-original")
|
||||
states = StateRepositoryStub(state)
|
||||
|
||||
result = StepByStepTourService.is_eligible(_account(initialized_at=datetime(2026, 6, 28)))
|
||||
result = _service(states=states).get_state(_context(workspace_id="workspace-current"))
|
||||
|
||||
assert result is True
|
||||
assert result.first_workspace_id == "workspace-original"
|
||||
assert states.initialize_calls == [("account-1", "workspace-current")]
|
||||
assert states.mutation_account_ids == []
|
||||
|
||||
|
||||
def test_get_state_does_not_create_state_for_ineligible_account_without_existing_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
|
||||
def test_get_state_does_not_create_state_for_ineligible_account() -> None:
|
||||
states = StateRepositoryStub()
|
||||
service = _service(states=states, account=_account(started_at=datetime(2026, 5, 31)))
|
||||
|
||||
result = StepByStepTourService.get_state(
|
||||
account=_account(initialized_at=datetime(2026, 5, 31)),
|
||||
current_tenant_id="workspace-1",
|
||||
session=sqlite_session,
|
||||
result = service.get_state(_context())
|
||||
|
||||
assert result == StepByStepTourResult()
|
||||
assert states.get_account_ids == ["account-1"]
|
||||
assert states.mutation_account_ids == []
|
||||
|
||||
|
||||
def test_get_state_does_not_create_state_when_tour_is_disabled() -> None:
|
||||
states = StateRepositoryStub()
|
||||
|
||||
result = _service(states=states, enabled=False).get_state(_context())
|
||||
|
||||
assert result == StepByStepTourResult()
|
||||
assert states.get_account_ids == ["account-1"]
|
||||
|
||||
|
||||
def test_patch_state_persists_even_when_tour_is_disabled() -> None:
|
||||
states = StateRepositoryStub()
|
||||
service = _service(states=states, enabled=False)
|
||||
|
||||
result = service.patch_state(_context(workspace_id="workspace-2"), StepByStepTourPatch("enable_current_workspace"))
|
||||
|
||||
assert result.manually_enabled_workspace_ids == ("workspace-2",)
|
||||
assert states.mutation_account_ids == ["account-1"]
|
||||
|
||||
|
||||
def test_patch_state_skip_removes_current_workspace_enable() -> None:
|
||||
states = StateRepositoryStub(
|
||||
StepByStepTourState(
|
||||
account_id="account-1",
|
||||
manually_enabled_workspace_ids=("workspace-1", "workspace-2"),
|
||||
)
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"first_workspace_id": None,
|
||||
"skipped": False,
|
||||
"completed_task_ids": [],
|
||||
"manually_enabled_workspace_ids": [],
|
||||
"manually_disabled_workspace_ids": [],
|
||||
"updated_at": None,
|
||||
}
|
||||
with sqlite_session_factory() as observer:
|
||||
assert _load_state(observer) is None
|
||||
result = _service(states=states).patch_state(_context(), StepByStepTourPatch("skip"))
|
||||
|
||||
assert result.skipped is True
|
||||
assert result.manually_enabled_workspace_ids == ("workspace-2",)
|
||||
|
||||
|
||||
def test_patch_state_persists_even_when_account_is_not_eligible(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-2",
|
||||
patch={"action": "enable_current_workspace"},
|
||||
session=sqlite_session,
|
||||
def test_patch_state_disable_moves_current_workspace_to_disabled() -> None:
|
||||
states = StateRepositoryStub(
|
||||
StepByStepTourState(
|
||||
account_id="account-1",
|
||||
manually_enabled_workspace_ids=("workspace-1", "workspace-2"),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["skipped"] is False
|
||||
assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
|
||||
assert result["manually_disabled_workspace_ids"] == []
|
||||
with sqlite_session_factory() as observer:
|
||||
persisted = _load_state(observer)
|
||||
assert persisted is not None
|
||||
assert persisted.manually_enabled_workspace_ids == ["workspace-2"]
|
||||
|
||||
|
||||
def test_patch_state_skip_action_sets_skipped_and_removes_current_workspace_enable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
state = _state()
|
||||
state.manually_enabled_workspace_ids = ["workspace-1", "workspace-2"]
|
||||
_persist_state(sqlite_session, state)
|
||||
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
patch={"action": "skip"},
|
||||
session=sqlite_session,
|
||||
result = _service(states=states).patch_state(
|
||||
_context(),
|
||||
StepByStepTourPatch("disable_current_workspace"),
|
||||
)
|
||||
|
||||
assert result["skipped"] is True
|
||||
assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
|
||||
assert result["manually_disabled_workspace_ids"] == []
|
||||
assert _load_state(sqlite_session) is state
|
||||
assert result.manually_enabled_workspace_ids == ("workspace-2",)
|
||||
assert result.manually_disabled_workspace_ids == ("workspace-1",)
|
||||
|
||||
|
||||
def test_patch_state_disable_action_moves_current_workspace_to_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
state = _state()
|
||||
state.manually_enabled_workspace_ids = ["workspace-1", "workspace-2"]
|
||||
_persist_state(sqlite_session, state)
|
||||
def test_patch_state_complete_and_uncomplete_task() -> None:
|
||||
states = StateRepositoryStub(StepByStepTourState(account_id="account-1", completed_task_ids=("home",)))
|
||||
service = _service(states=states)
|
||||
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
patch={"action": "disable_current_workspace"},
|
||||
session=sqlite_session,
|
||||
service.patch_state(_context(), StepByStepTourPatch("complete_task", "studio"))
|
||||
result = service.patch_state(_context(), StepByStepTourPatch("uncomplete_task", "home"))
|
||||
|
||||
assert result.completed_task_ids == ("studio",)
|
||||
|
||||
|
||||
def test_rejects_unsupported_task_id() -> None:
|
||||
with pytest.raises(ValueError, match="Unsupported task_id"):
|
||||
StepByStepTourService._require_task_id("unknown")
|
||||
|
||||
|
||||
def test_rejects_missing_workspace_before_using_state_repository() -> None:
|
||||
states = StateRepositoryStub()
|
||||
|
||||
with pytest.raises(RuntimeError, match="did not resolve an active workspace"):
|
||||
_service(states=states).patch_state(_context(workspace_id=None), StepByStepTourPatch("skip"))
|
||||
|
||||
assert states.mutation_account_ids == []
|
||||
|
||||
|
||||
def test_get_state_rejects_unknown_admitted_account() -> None:
|
||||
states = StateRepositoryStub()
|
||||
service = StepByStepTourService(
|
||||
accounts=_accounts(None),
|
||||
states=states,
|
||||
enabled=True,
|
||||
rollout_started_at=datetime(2026, 6, 1),
|
||||
)
|
||||
|
||||
assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
|
||||
assert result["manually_disabled_workspace_ids"] == ["workspace-1"]
|
||||
assert _load_state(sqlite_session) is state
|
||||
|
||||
|
||||
def test_patch_state_complete_and_uncomplete_task(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
state = _state()
|
||||
state.completed_task_ids = ["home"]
|
||||
_persist_state(sqlite_session, state)
|
||||
|
||||
StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
patch={"action": "complete_task", "task_id": "studio"},
|
||||
session=sqlite_session,
|
||||
)
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
patch={"action": "uncomplete_task", "task_id": "home"},
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result["completed_task_ids"] == ["studio"]
|
||||
|
||||
|
||||
def test_patch_state_recovers_when_concurrent_request_created_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
existing_state = _state()
|
||||
existing_state.manually_enabled_workspace_ids = ["workspace-1"]
|
||||
lifecycle_events: list[str] = []
|
||||
|
||||
@event.listens_for(sqlite_session, "before_flush", once=True)
|
||||
def add_conflicting_pending_state(session: Session, _flush_context, _instances) -> None:
|
||||
lifecycle_events.append("before_flush")
|
||||
session.add(AccountStepByStepTourState(account_id="account-1"))
|
||||
|
||||
@event.listens_for(sqlite_session, "after_soft_rollback", once=True)
|
||||
def persist_winning_request(_session: Session, _previous_transaction) -> None:
|
||||
lifecycle_events.append("after_soft_rollback")
|
||||
with sqlite_session_factory() as winner:
|
||||
winner.add(existing_state)
|
||||
winner.commit()
|
||||
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-2",
|
||||
patch={"action": "enable_current_workspace"},
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result["manually_enabled_workspace_ids"] == ["workspace-1", "workspace-2"]
|
||||
assert lifecycle_events == ["before_flush", "after_soft_rollback"]
|
||||
with sqlite_session_factory() as observer:
|
||||
persisted = _load_state(observer)
|
||||
assert persisted is not None
|
||||
assert persisted.manually_enabled_workspace_ids == ["workspace-1", "workspace-2"]
|
||||
with pytest.raises(RuntimeError, match="unknown account"):
|
||||
service.get_state(_context())
|
||||
|
||||
@ -1,154 +1,260 @@
|
||||
"""Unit tests for the ``resume_agent_app_execution`` celery task (ENG-635).
|
||||
|
||||
Every DB access (``db.session.get``) and the generator are patched at the module
|
||||
level, so the task's branch logic is exercised without a database or live stack.
|
||||
"""
|
||||
"""Unit tests for the ``resume_agent_app_execution`` Celery task (ENG-635)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from models.account import Account
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.enums import ConversationFromSource, EndUserType
|
||||
from models.enums import InvokeFrom as StoredInvokeFrom
|
||||
from models.human_input import HumanInputForm
|
||||
from models.model import App, Conversation, EndUser
|
||||
from models.model import App, AppMode, Conversation, EndUser
|
||||
from tasks.app_generate import resume_agent_app_task as mod
|
||||
|
||||
MODULE = "tasks.app_generate.resume_agent_app_task"
|
||||
|
||||
|
||||
def _form(conversation_id: str = "conv-1", app_id: str = "app-1") -> MagicMock:
|
||||
return MagicMock(conversation_id=conversation_id, app_id=app_id)
|
||||
@pytest.fixture
|
||||
def task_session(mocker: MockerFixture, sqlite_session_factory: sessionmaker[Session]) -> Iterator[Session]:
|
||||
"""Bind the task's Flask-SQLAlchemy session proxy to the shared SQLite database."""
|
||||
registry = scoped_session(sqlite_session_factory)
|
||||
mocker.patch.object(mod.db, "session", registry)
|
||||
session = registry()
|
||||
yield session
|
||||
registry.remove()
|
||||
|
||||
|
||||
def _wire_db(
|
||||
mocker: MockerFixture,
|
||||
def _app(*, app_id: str, tenant_id: str) -> App:
|
||||
return App(
|
||||
id=app_id,
|
||||
tenant_id=tenant_id,
|
||||
name="Agent app",
|
||||
description="",
|
||||
mode=AppMode.AGENT_CHAT,
|
||||
icon_type=None,
|
||||
icon=None,
|
||||
icon_background=None,
|
||||
enable_site=False,
|
||||
enable_api=False,
|
||||
)
|
||||
|
||||
|
||||
def _conversation(
|
||||
*,
|
||||
form=None,
|
||||
app=None,
|
||||
conversation=None,
|
||||
account=None,
|
||||
end_user=None,
|
||||
) -> MagicMock:
|
||||
"""Patch the module ``db`` so ``db.session.get(Model, id)`` dispatches by model."""
|
||||
table = {
|
||||
HumanInputForm: form,
|
||||
App: app,
|
||||
Conversation: conversation,
|
||||
Account: account,
|
||||
EndUser: end_user,
|
||||
}
|
||||
db = mocker.patch(f"{MODULE}.db")
|
||||
db.session.get.side_effect = lambda model, _id: table.get(model)
|
||||
return db
|
||||
conversation_id: str,
|
||||
app_id: str,
|
||||
account_id: str | None = None,
|
||||
end_user_id: str | None = None,
|
||||
invoke_from: StoredInvokeFrom = StoredInvokeFrom.WEB_APP,
|
||||
) -> Conversation:
|
||||
return Conversation(
|
||||
id=conversation_id,
|
||||
app_id=app_id,
|
||||
mode=AppMode.AGENT_CHAT,
|
||||
name="Agent conversation",
|
||||
inputs={},
|
||||
invoke_from=invoke_from,
|
||||
from_source=ConversationFromSource.API,
|
||||
from_account_id=account_id,
|
||||
from_end_user_id=end_user_id,
|
||||
)
|
||||
|
||||
|
||||
def test_resume_happy_path_account_user_sets_tenant_and_runs(mocker: MockerFixture):
|
||||
conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP)
|
||||
account = MagicMock()
|
||||
app = MagicMock(tenant_id="tenant-1")
|
||||
db = _wire_db(mocker, form=_form(), app=app, conversation=conversation, account=account)
|
||||
gen = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
|
||||
mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1")
|
||||
|
||||
account.set_tenant_id_with_session.assert_called_once_with("tenant-1", session=db.session.return_value)
|
||||
gen.return_value.resume_after_form_submission.assert_called_once()
|
||||
kwargs = gen.return_value.resume_after_form_submission.call_args.kwargs
|
||||
assert kwargs["conversation_id"] == "conv-1"
|
||||
assert kwargs["form_id"] == "form-1"
|
||||
assert kwargs["user"] is account
|
||||
assert kwargs["app_model"] is app
|
||||
assert kwargs["invoke_from"] == InvokeFrom.WEB_APP
|
||||
assert kwargs["session"] is db.session.return_value
|
||||
def _form(*, form_id: str, conversation_id: str, app_id: str) -> HumanInputForm:
|
||||
return HumanInputForm(
|
||||
id=form_id,
|
||||
tenant_id=str(uuid4()),
|
||||
app_id=app_id,
|
||||
workflow_run_id=None,
|
||||
conversation_id=conversation_id,
|
||||
form_kind=HumanInputFormKind.RUNTIME,
|
||||
node_id="ask-human",
|
||||
form_definition="{}",
|
||||
rendered_content="Question",
|
||||
status=HumanInputFormStatus.WAITING,
|
||||
expiration_time=datetime.now(UTC).replace(tzinfo=None) + timedelta(hours=1),
|
||||
)
|
||||
|
||||
|
||||
def test_resume_end_user_path(mocker: MockerFixture):
|
||||
conversation = MagicMock(from_account_id=None, from_end_user_id="eu-1", invoke_from=InvokeFrom.WEB_APP)
|
||||
end_user = MagicMock()
|
||||
_wire_db(mocker, form=_form(), app=MagicMock(tenant_id="t"), conversation=conversation, end_user=end_user)
|
||||
gen = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
|
||||
mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1")
|
||||
|
||||
assert gen.return_value.resume_after_form_submission.call_args.kwargs["user"] is end_user
|
||||
def _seed_account(session: Session, *, tenant_id: str, account_id: str) -> Account:
|
||||
tenant = Tenant(name="Tenant")
|
||||
tenant.id = tenant_id
|
||||
account = Account(name="Account", email="account@example.com")
|
||||
account.id = account_id
|
||||
join = TenantAccountJoin(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
current=True,
|
||||
role=TenantAccountRole.NORMAL,
|
||||
)
|
||||
session.add_all([tenant, account, join])
|
||||
return account
|
||||
|
||||
|
||||
def test_resume_preserves_debugger_invoke_from(mocker: MockerFixture):
|
||||
conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None, invoke_from=InvokeFrom.DEBUGGER)
|
||||
account = MagicMock()
|
||||
app = MagicMock(tenant_id="tenant-1")
|
||||
_wire_db(mocker, form=_form(), app=app, conversation=conversation, account=account)
|
||||
gen = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
def test_resume_happy_path_account_user_sets_tenant_and_runs(mocker: MockerFixture, task_session: Session) -> None:
|
||||
tenant_id, app_id, conversation_id, form_id, account_id = (str(uuid4()) for _ in range(5))
|
||||
app = _app(app_id=app_id, tenant_id=tenant_id)
|
||||
account = _seed_account(task_session, tenant_id=tenant_id, account_id=account_id)
|
||||
conversation = _conversation(conversation_id=conversation_id, app_id=app_id, account_id=account_id)
|
||||
task_session.add_all([app, conversation, _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id)])
|
||||
task_session.commit()
|
||||
generator = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
|
||||
mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1")
|
||||
mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id)
|
||||
|
||||
assert gen.return_value.resume_after_form_submission.call_args.kwargs["invoke_from"] == InvokeFrom.DEBUGGER
|
||||
call = generator.return_value.resume_after_form_submission.call_args
|
||||
assert call is not None
|
||||
assert call.kwargs["conversation_id"] == conversation_id
|
||||
assert call.kwargs["form_id"] == form_id
|
||||
assert call.kwargs["user"] is account
|
||||
assert call.kwargs["app_model"] is app
|
||||
assert call.kwargs["invoke_from"] == InvokeFrom.WEB_APP
|
||||
assert isinstance(call.kwargs["session"], Session)
|
||||
assert account.current_tenant_id == tenant_id
|
||||
|
||||
|
||||
def test_resume_returns_when_form_missing(mocker: MockerFixture):
|
||||
_wire_db(mocker, form=None)
|
||||
gen = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
def test_resume_end_user_path(mocker: MockerFixture, task_session: Session) -> None:
|
||||
tenant_id, app_id, conversation_id, form_id, end_user_id = (str(uuid4()) for _ in range(5))
|
||||
app = _app(app_id=app_id, tenant_id=tenant_id)
|
||||
end_user = EndUser(
|
||||
id=end_user_id,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
type=EndUserType.BROWSER,
|
||||
name="End user",
|
||||
session_id="browser-session",
|
||||
)
|
||||
task_session.add_all(
|
||||
[
|
||||
app,
|
||||
end_user,
|
||||
_conversation(conversation_id=conversation_id, app_id=app_id, end_user_id=end_user_id),
|
||||
_form(form_id=form_id, conversation_id=conversation_id, app_id=app_id),
|
||||
]
|
||||
)
|
||||
task_session.commit()
|
||||
generator = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
|
||||
mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1")
|
||||
mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id)
|
||||
|
||||
gen.assert_not_called()
|
||||
assert generator.return_value.resume_after_form_submission.call_args.kwargs["user"] is end_user
|
||||
|
||||
|
||||
def test_resume_returns_on_conversation_mismatch(mocker: MockerFixture):
|
||||
_wire_db(mocker, form=_form(conversation_id="other-conv"))
|
||||
gen = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
def test_resume_preserves_debugger_invoke_from(mocker: MockerFixture, task_session: Session) -> None:
|
||||
tenant_id, app_id, conversation_id, form_id, account_id = (str(uuid4()) for _ in range(5))
|
||||
app = _app(app_id=app_id, tenant_id=tenant_id)
|
||||
_seed_account(task_session, tenant_id=tenant_id, account_id=account_id)
|
||||
task_session.add_all(
|
||||
[
|
||||
app,
|
||||
_conversation(
|
||||
conversation_id=conversation_id,
|
||||
app_id=app_id,
|
||||
account_id=account_id,
|
||||
invoke_from=StoredInvokeFrom.DEBUGGER,
|
||||
),
|
||||
_form(form_id=form_id, conversation_id=conversation_id, app_id=app_id),
|
||||
]
|
||||
)
|
||||
task_session.commit()
|
||||
generator = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
|
||||
mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1")
|
||||
mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id)
|
||||
|
||||
gen.assert_not_called()
|
||||
assert generator.return_value.resume_after_form_submission.call_args.kwargs["invoke_from"] == InvokeFrom.DEBUGGER
|
||||
|
||||
|
||||
def test_resume_returns_when_app_missing(mocker: MockerFixture):
|
||||
_wire_db(mocker, form=_form(), app=None)
|
||||
gen = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
|
||||
mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1")
|
||||
|
||||
gen.assert_not_called()
|
||||
@pytest.mark.usefixtures("task_session")
|
||||
def test_resume_returns_when_form_missing(mocker: MockerFixture) -> None:
|
||||
generator = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
mod.resume_agent_app_execution(conversation_id=str(uuid4()), form_id=str(uuid4()))
|
||||
generator.assert_not_called()
|
||||
|
||||
|
||||
def test_resume_returns_when_conversation_missing(mocker: MockerFixture):
|
||||
_wire_db(mocker, form=_form(), app=MagicMock(), conversation=None)
|
||||
gen = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
|
||||
mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1")
|
||||
|
||||
gen.assert_not_called()
|
||||
def test_resume_returns_on_conversation_mismatch(mocker: MockerFixture, task_session: Session) -> None:
|
||||
app_id, form_id = str(uuid4()), str(uuid4())
|
||||
task_session.add(_form(form_id=form_id, conversation_id=str(uuid4()), app_id=app_id))
|
||||
task_session.commit()
|
||||
generator = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
mod.resume_agent_app_execution(conversation_id=str(uuid4()), form_id=form_id)
|
||||
generator.assert_not_called()
|
||||
|
||||
|
||||
def test_resume_returns_when_no_user_resolvable(mocker: MockerFixture):
|
||||
conversation = MagicMock(from_account_id=None, from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP)
|
||||
_wire_db(mocker, form=_form(), app=MagicMock(), conversation=conversation)
|
||||
gen = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
|
||||
mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1")
|
||||
|
||||
gen.assert_not_called()
|
||||
def test_resume_returns_when_app_missing(mocker: MockerFixture, task_session: Session) -> None:
|
||||
conversation_id, form_id = str(uuid4()), str(uuid4())
|
||||
task_session.add(_form(form_id=form_id, conversation_id=conversation_id, app_id=str(uuid4())))
|
||||
task_session.commit()
|
||||
generator = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id)
|
||||
generator.assert_not_called()
|
||||
|
||||
|
||||
def test_resume_returns_when_account_id_set_but_account_gone(mocker: MockerFixture):
|
||||
conversation = MagicMock(from_account_id="acct-x", from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP)
|
||||
_wire_db(mocker, form=_form(), app=MagicMock(), conversation=conversation, account=None)
|
||||
gen = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
|
||||
mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1")
|
||||
|
||||
gen.assert_not_called()
|
||||
def test_resume_returns_when_conversation_missing(mocker: MockerFixture, task_session: Session) -> None:
|
||||
tenant_id, app_id, conversation_id, form_id = (str(uuid4()) for _ in range(4))
|
||||
task_session.add_all(
|
||||
[
|
||||
_app(app_id=app_id, tenant_id=tenant_id),
|
||||
_form(form_id=form_id, conversation_id=conversation_id, app_id=app_id),
|
||||
]
|
||||
)
|
||||
task_session.commit()
|
||||
generator = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id)
|
||||
generator.assert_not_called()
|
||||
|
||||
|
||||
def test_resume_swallows_generator_exception(mocker: MockerFixture):
|
||||
conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP)
|
||||
_wire_db(mocker, form=_form(), app=MagicMock(tenant_id="t"), conversation=conversation, account=MagicMock())
|
||||
gen = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
gen.return_value.resume_after_form_submission.side_effect = RuntimeError("boom")
|
||||
def test_resume_returns_when_no_user_resolvable(mocker: MockerFixture, task_session: Session) -> None:
|
||||
tenant_id, app_id, conversation_id, form_id = (str(uuid4()) for _ in range(4))
|
||||
task_session.add_all(
|
||||
[
|
||||
_app(app_id=app_id, tenant_id=tenant_id),
|
||||
_conversation(conversation_id=conversation_id, app_id=app_id),
|
||||
_form(form_id=form_id, conversation_id=conversation_id, app_id=app_id),
|
||||
]
|
||||
)
|
||||
task_session.commit()
|
||||
generator = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id)
|
||||
generator.assert_not_called()
|
||||
|
||||
# The task must not propagate the failure (it is logged and the session closed).
|
||||
mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1")
|
||||
|
||||
def test_resume_returns_when_account_id_set_but_account_gone(mocker: MockerFixture, task_session: Session) -> None:
|
||||
tenant_id, app_id, conversation_id, form_id = (str(uuid4()) for _ in range(4))
|
||||
task_session.add_all(
|
||||
[
|
||||
_app(app_id=app_id, tenant_id=tenant_id),
|
||||
_conversation(conversation_id=conversation_id, app_id=app_id, account_id=str(uuid4())),
|
||||
_form(form_id=form_id, conversation_id=conversation_id, app_id=app_id),
|
||||
]
|
||||
)
|
||||
task_session.commit()
|
||||
generator = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id)
|
||||
generator.assert_not_called()
|
||||
|
||||
|
||||
def test_resume_swallows_generator_exception(mocker: MockerFixture, task_session: Session) -> None:
|
||||
tenant_id, app_id, conversation_id, form_id, account_id = (str(uuid4()) for _ in range(5))
|
||||
_seed_account(task_session, tenant_id=tenant_id, account_id=account_id)
|
||||
task_session.add_all(
|
||||
[
|
||||
_app(app_id=app_id, tenant_id=tenant_id),
|
||||
_conversation(conversation_id=conversation_id, app_id=app_id, account_id=account_id),
|
||||
_form(form_id=form_id, conversation_id=conversation_id, app_id=app_id),
|
||||
]
|
||||
)
|
||||
task_session.commit()
|
||||
generator = mocker.patch(f"{MODULE}.AgentAppGenerator")
|
||||
generator.return_value.resume_after_form_submission.side_effect = RuntimeError("boom")
|
||||
|
||||
mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id)
|
||||
|
||||
generator.return_value.resume_after_form_submission.assert_called_once()
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
# Redis
|
||||
# Redis connection URL for run records and per-run event streams.
|
||||
DIFY_AGENT_REDIS_URL=redis://:difyai123456localhost:6379/0
|
||||
DIFY_AGENT_REDIS_URL=redis://:difyai123456@localhost:6379/0
|
||||
# Prefix for Redis run-record and event-stream keys.
|
||||
DIFY_AGENT_REDIS_PREFIX=dify-agent
|
||||
|
||||
@ -24,14 +24,14 @@ DIFY_AGENT_PLUGIN_DAEMON_API_KEY=lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc
|
||||
# Base URL for Dify API inner endpoints used by Agent Stub config and file requests.
|
||||
DIFY_AGENT_INNER_API_URL=http://localhost:5001
|
||||
# Must match API/worker INNER_API_KEY_FOR_PLUGIN, not the generic INNER_API_KEY.
|
||||
DIFY_AGENT_INNER_API_KEY=
|
||||
DIFY_AGENT_INNER_API_KEY=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
|
||||
|
||||
# Runtime resources
|
||||
# Select one coherent Home Snapshot + Execution Binding backend: local, enterprise, or e2b.
|
||||
DIFY_AGENT_RUNTIME_BACKEND=local
|
||||
# Local backend: shellctl data-plane URL and optional bearer token.
|
||||
# Leave the endpoint empty when this server will not provide dify.runtime or resource endpoints.
|
||||
DIFY_AGENT_LOCAL_SANDBOX_ENDPOINT=
|
||||
DIFY_AGENT_LOCAL_SANDBOX_ENDPOINT=http://localhost:5004
|
||||
DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN=
|
||||
# Enterprise resource operations currently fail fast with NotImplementedError.
|
||||
# These names are retained for the configured Enterprise Gateway boundary.
|
||||
@ -54,12 +54,12 @@ DIFY_AGENT_SHELL_REDACT_PATTERNS=
|
||||
# Public Agent Stub URL reachable from shellctl-managed remote machines.
|
||||
# Use an HTTP(S) service root or an explicit /agent-stub API root.
|
||||
# Leave empty to avoid injecting DIFY_AGENT_STUB_* into shell.run jobs.
|
||||
DIFY_AGENT_STUB_API_BASE_URL=http://localhost:5050/agent-stub
|
||||
DIFY_AGENT_STUB_API_BASE_URL=http://host.docker.internal:5050/agent-stub
|
||||
# Optional bind override used only when DIFY_AGENT_STUB_API_BASE_URL uses grpc://.
|
||||
DIFY_AGENT_STUB_GRPC_BIND_ADDRESS=
|
||||
# Dify API base URL reachable from the Sandbox for the signed /files/* data plane,
|
||||
# including Config file and skill pulls.
|
||||
DIFY_AGENT_SANDBOX_FILES_BASE_URL=http://localhost:5001
|
||||
DIFY_AGENT_SANDBOX_FILES_BASE_URL=http://host.docker.internal:5001
|
||||
# Maximum Agent Stub upload size in MiB; forwarded to Dify API as a signed byte limit.
|
||||
DIFY_AGENT_STUB_UPLOAD_FILE_SIZE_LIMIT=50
|
||||
# Shell command deadline for converting a Binding file to a ToolFile.
|
||||
@ -84,6 +84,6 @@ DIFY_AGENT_OUTBOUND_HTTP_POOL_TIMEOUT=10
|
||||
DIFY_AGENT_OUTBOUND_HTTP_MAX_CONNECTIONS=100
|
||||
DIFY_AGENT_OUTBOUND_HTTP_MAX_KEEPALIVE_CONNECTIONS=20
|
||||
DIFY_AGENT_OUTBOUND_HTTP_KEEPALIVE_EXPIRY=30
|
||||
DIFY_AGENT_LOCAL_SANDBOX_MATERIALIZED_HOME_ROOT=
|
||||
DIFY_AGENT_LOCAL_SANDBOX_WORKSPACE_ROOT=
|
||||
DIFY_AGENT_LOCAL_SANDBOX_HOME_SNAPSHOT_ROOT=
|
||||
DIFY_AGENT_LOCAL_SANDBOX_MATERIALIZED_HOME_ROOT=/home/dify
|
||||
DIFY_AGENT_LOCAL_SANDBOX_WORKSPACE_ROOT=/workspace
|
||||
DIFY_AGENT_LOCAL_SANDBOX_HOME_SNAPSHOT_ROOT=/home/dify/.snapshots
|
||||
|
||||
@ -127,6 +127,30 @@ services:
|
||||
networks:
|
||||
- ssrf_proxy_network
|
||||
|
||||
# Local sandbox for Dify Agent shell workspaces (shellctl data plane).
|
||||
# Exposes port 5004 on the host so a locally-run agent backend can reach it
|
||||
# at http://localhost:5004 (DIFY_AGENT_LOCAL_SANDBOX_ENDPOINT).
|
||||
local_sandbox:
|
||||
image: langgenius/dify-agent-local-sandbox:1.17.0
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
SHELLCTL_AUTH_TOKEN: ${DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN:-${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-}}
|
||||
ports:
|
||||
- "${EXPOSE_LOCAL_SANDBOX_PORT:-5004}:5004"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5004/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
volumes:
|
||||
- dify_agent_local_sandbox_home:/home/dify
|
||||
- dify_agent_local_sandbox_workspace:/workspace
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.6.10-local
|
||||
@ -259,3 +283,7 @@ networks:
|
||||
ssrf_proxy_network:
|
||||
driver: bridge
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
dify_agent_local_sandbox_home:
|
||||
dify_agent_local_sandbox_workspace:
|
||||
|
||||
@ -106,6 +106,12 @@ SANDBOX_HTTP_PROXY=http://ssrf_proxy:3128
|
||||
SANDBOX_HTTPS_PROXY=http://ssrf_proxy:3128
|
||||
SANDBOX_PORT=8194
|
||||
|
||||
# ------------------------------
|
||||
# Environment Variables for local_sandbox Service (Dify Agent shell workspaces)
|
||||
# ------------------------------
|
||||
# Leave empty to disable shellctl auth (local development default).
|
||||
DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN=
|
||||
|
||||
# ------------------------------
|
||||
# Environment Variables for ssrf_proxy Service
|
||||
# ------------------------------
|
||||
@ -145,6 +151,7 @@ EXPOSE_POSTGRES_PORT=5432
|
||||
EXPOSE_MYSQL_PORT=3306
|
||||
EXPOSE_REDIS_PORT=6379
|
||||
EXPOSE_SANDBOX_PORT=8194
|
||||
EXPOSE_LOCAL_SANDBOX_PORT=5004
|
||||
EXPOSE_SSRF_PROXY_PORT=3128
|
||||
EXPOSE_WEAVIATE_PORT=8080
|
||||
|
||||
|
||||
@ -355,6 +355,7 @@ export type OpenApiErrorCode =
|
||||
| 'request_entity_too_large'
|
||||
| 'too_many_files'
|
||||
| 'too_many_requests'
|
||||
| 'trigger_workflow_service_mode_unavailable'
|
||||
| 'unauthorized'
|
||||
| 'unknown'
|
||||
| 'unsupported_file_type'
|
||||
|
||||
@ -446,6 +446,7 @@ export const zOpenApiErrorCode = z.enum([
|
||||
'request_entity_too_large',
|
||||
'too_many_files',
|
||||
'too_many_requests',
|
||||
'trigger_workflow_service_mode_unavailable',
|
||||
'unauthorized',
|
||||
'unknown',
|
||||
'unsupported_file_type',
|
||||
|
||||
106
web/app/(commonLayout)/__tests__/external-service-sync.spec.tsx
Normal file
106
web/app/(commonLayout)/__tests__/external-service-sync.spec.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { REGISTRATION_SUCCESS_STORAGE_KEY } from '@/app/components/base/amplitude/registration-session-state'
|
||||
import { rememberRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
|
||||
import { AmplitudeIdentitySync } from '../external-service-sync'
|
||||
|
||||
const { mockSetUserId, mockSetUserProperties, mockTrackEvent } = vi.hoisted(() => ({
|
||||
mockSetUserId: vi.fn(),
|
||||
mockSetUserProperties: vi.fn(),
|
||||
mockTrackEvent: vi.fn((..._args: unknown[]) => ({
|
||||
promise: Promise.resolve({ code: 200 }),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...original,
|
||||
useSuspenseQuery: () => ({
|
||||
data: {
|
||||
id: 'account-id',
|
||||
email: 'person@example.com',
|
||||
name: 'Person',
|
||||
is_password_set: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('jotai', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('jotai')>()
|
||||
return {
|
||||
...original,
|
||||
useAtomValue: () => ({
|
||||
id: 'workspace-id',
|
||||
name: 'Workspace',
|
||||
plan: 'professional',
|
||||
role: 'owner',
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/account-profile/client', () => ({
|
||||
userProfileQueryOptions: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
setUserId: (...args: unknown[]) => mockSetUserId(...args),
|
||||
setUserProperties: (...args: unknown[]) => mockSetUserProperties(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude/utils', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude/init', () => ({
|
||||
getIsAmplitudeInitialized: () => true,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/analytics-consent/consent-store', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@/app/components/base/analytics-consent/consent-store')>()
|
||||
return {
|
||||
...original,
|
||||
getAnalyticsConsent: () => 'granted',
|
||||
}
|
||||
})
|
||||
|
||||
describe('AmplitudeIdentitySync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.sessionStorage.clear()
|
||||
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
)
|
||||
})
|
||||
|
||||
it('sets identity before flushing a marker that already exists', async () => {
|
||||
rememberRegistrationSuccess({ method: 'oauth' })
|
||||
|
||||
render(<AmplitudeIdentitySync />)
|
||||
|
||||
await waitFor(() => expect(mockTrackEvent).toHaveBeenCalledTimes(1))
|
||||
expect(mockSetUserId).toHaveBeenCalledWith('person@example.com')
|
||||
expect(mockSetUserProperties).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetUserId.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockTrackEvent.mock.invocationCallOrder[0]!,
|
||||
)
|
||||
expect(mockSetUserProperties.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockTrackEvent.mock.invocationCallOrder[0]!,
|
||||
)
|
||||
})
|
||||
|
||||
it('flushes a marker created after identity sync without repeating unchanged identity updates', async () => {
|
||||
render(<AmplitudeIdentitySync />)
|
||||
|
||||
await waitFor(() => expect(mockSetUserId).toHaveBeenCalledTimes(1))
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
|
||||
await waitFor(() => expect(mockTrackEvent).toHaveBeenCalledTimes(1))
|
||||
expect(mockSetUserId).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetUserProperties).toHaveBeenCalledTimes(1)
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -238,9 +238,11 @@ describe('AppDetailLayout', () => {
|
||||
expect(useStore.getState().appDetail?.id).toBe('app-1')
|
||||
})
|
||||
|
||||
it('should allow access point pages without app deploy or app ACL permissions', async () => {
|
||||
it('should allow users with access point permission to open access point directly', async () => {
|
||||
mockPathname = '/app/app-1/access-point'
|
||||
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [] }))
|
||||
mockFetchAppDetailDirect.mockResolvedValue(
|
||||
createAppDetail({ permission_keys: [AppACLPermission.AccessPoint] }),
|
||||
)
|
||||
|
||||
render(
|
||||
<AppDetailLayout appId="app-1">
|
||||
@ -254,6 +256,44 @@ describe('AppDetailLayout', () => {
|
||||
expect(useStore.getState().appDetail?.id).toBe('app-1')
|
||||
})
|
||||
|
||||
it('should redirect access point pages when access point permission is missing', async () => {
|
||||
mockPathname = '/app/app-1/access-point'
|
||||
mockFetchAppDetailDirect.mockResolvedValue(
|
||||
createAppDetail({ permission_keys: [AppACLPermission.Monitor] }),
|
||||
)
|
||||
|
||||
render(
|
||||
<AppDetailLayout appId="app-1">
|
||||
<div>App page content</div>
|
||||
</AppDetailLayout>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/app/app-1/overview')
|
||||
})
|
||||
expect(screen.queryByText('App page content')).not.toBeInTheDocument()
|
||||
expect(useStore.getState().appDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should keep access point content hidden while redirecting cached app data without permission', async () => {
|
||||
mockPathname = '/app/app-1/access-point'
|
||||
useStore
|
||||
.getState()
|
||||
.setAppDetail(createAppDetail({ permission_keys: [AppACLPermission.Monitor] }))
|
||||
|
||||
render(
|
||||
<AppDetailLayout appId="app-1">
|
||||
<div>App page content</div>
|
||||
</AppDetailLayout>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('App page content')).not.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/app/app-1/overview')
|
||||
})
|
||||
expect(mockFetchAppDetailDirect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should redirect deploy pages when app deploy ACL permission is missing', async () => {
|
||||
mockPathname = '/app/app-1/deploy'
|
||||
mockFetchAppDetailDirect.mockResolvedValue(
|
||||
@ -317,7 +357,7 @@ describe('AppDetailLayout', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/app/app-1/access-point')
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps')
|
||||
})
|
||||
expect(screen.queryByText('App page content')).not.toBeInTheDocument()
|
||||
expect(useStore.getState().appDetail).toBeUndefined()
|
||||
@ -488,7 +528,7 @@ describe('AppDetailLayout', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/app/app-1/access-point')
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps')
|
||||
})
|
||||
expect(screen.queryByText('App page content')).not.toBeInTheDocument()
|
||||
expect(useStore.getState().appDetail).toBeUndefined()
|
||||
|
||||
@ -78,8 +78,28 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
appDetail?.id === appId ? appDetail : appDetailRes?.id === appId ? appDetailRes : null
|
||||
const pageTitle = appDetailPageTitle(pathname, t)
|
||||
const appName = routeAppDetail?.id === appId ? routeAppDetail.name : undefined
|
||||
const isAppACLContextReady =
|
||||
!!routeAppDetail &&
|
||||
!!currentWorkspace.id &&
|
||||
!isLoadingCurrentWorkspace &&
|
||||
!isLoadingWorkspacePermissionKeys &&
|
||||
!isLoadingAppDetail
|
||||
const appACLCapabilities = React.useMemo(
|
||||
() =>
|
||||
routeAppDetail && isAppACLContextReady
|
||||
? getAppACLCapabilities(routeAppDetail.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: routeAppDetail.maintainer,
|
||||
workspacePermissionKeys,
|
||||
isRbacEnabled,
|
||||
})
|
||||
: null,
|
||||
[currentUserId, isAppACLContextReady, isRbacEnabled, routeAppDetail, workspacePermissionKeys],
|
||||
)
|
||||
const shouldBlockAgentResourceAccess =
|
||||
routeAppDetail?.mode === AppModeEnum.AGENT && pathname.endsWith('/access-config')
|
||||
const shouldBlockAccessPointAccess =
|
||||
pathname.endsWith('/access-point') && !appACLCapabilities?.canAccessPoint
|
||||
|
||||
useDocumentTitle(`${pageTitle} · ${appName || t(($) => $['menus.appDetail'], { ns: 'common' })}`)
|
||||
|
||||
@ -120,28 +140,16 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
}, [appId, router, setAppDetail])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!routeAppDetail ||
|
||||
!currentWorkspace.id ||
|
||||
isLoadingCurrentWorkspace ||
|
||||
isLoadingWorkspacePermissionKeys ||
|
||||
isLoadingAppDetail
|
||||
)
|
||||
return
|
||||
if (!routeAppDetail || !isAppACLContextReady || !appACLCapabilities) return
|
||||
if (routeAppDetail.id !== appId) return
|
||||
|
||||
const appACLCapabilities = getAppACLCapabilities(routeAppDetail.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: routeAppDetail.maintainer,
|
||||
workspacePermissionKeys,
|
||||
isRbacEnabled,
|
||||
})
|
||||
const isLayoutPath = pathname.endsWith('configuration') || pathname.endsWith('workflow')
|
||||
const isLogsPath = pathname.endsWith('logs')
|
||||
const isAnnotationsPath = pathname.endsWith('annotations')
|
||||
const isOverviewPath = pathname.endsWith('overview')
|
||||
const isAccessConfigPath = pathname.endsWith('access-config')
|
||||
const isDeployPath = pathname.endsWith('deploy')
|
||||
const isAccessPointPath = pathname.endsWith('access-point')
|
||||
if (
|
||||
(isLayoutPath && !appACLCapabilities.canAccessLayout) ||
|
||||
(isLogsPath && !appACLCapabilities.canAccessLogAndAnnotation) ||
|
||||
@ -150,7 +158,8 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
(isAccessConfigPath &&
|
||||
(routeAppDetail.mode === AppModeEnum.AGENT || !appACLCapabilities.canAccessConfig)) ||
|
||||
(isDeployPath &&
|
||||
(routeAppDetail.mode !== AppModeEnum.WORKFLOW || !appACLCapabilities.canDeploy))
|
||||
(routeAppDetail.mode !== AppModeEnum.WORKFLOW || !appACLCapabilities.canDeploy)) ||
|
||||
(isAccessPointPath && !appACLCapabilities.canAccessPoint)
|
||||
) {
|
||||
router.replace(
|
||||
getRedirectionPath(routeAppDetail, {
|
||||
@ -180,14 +189,12 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
if (appDetailRes && appDetail?.id !== appDetailRes.id)
|
||||
setAppDetail({ ...appDetailRes, enable_sso: false })
|
||||
}, [
|
||||
appACLCapabilities,
|
||||
appDetail?.id,
|
||||
appDetailRes,
|
||||
appId,
|
||||
currentUserId,
|
||||
currentWorkspace.id,
|
||||
isLoadingAppDetail,
|
||||
isLoadingCurrentWorkspace,
|
||||
isLoadingWorkspacePermissionKeys,
|
||||
isAppACLContextReady,
|
||||
isRbacEnabled,
|
||||
pathname,
|
||||
routeAppDetail,
|
||||
@ -198,7 +205,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
|
||||
const isWorkflowPage = pathname.endsWith('/workflow')
|
||||
const content =
|
||||
!appDetail || shouldBlockAgentResourceAccess ? (
|
||||
!appDetail || shouldBlockAgentResourceAccess || shouldBlockAccessPointAccess ? (
|
||||
<div className="flex min-w-0 grow items-center justify-center bg-background-body">
|
||||
<Loading />
|
||||
</div>
|
||||
|
||||
@ -5,9 +5,13 @@ import type { DeploymentEdition } from '@dify/contracts/api/console/system-featu
|
||||
import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import { skipToken, useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { Fragment, useEffect, useRef } from 'react'
|
||||
import { Fragment, useEffect, useRef, useSyncExternalStore } from 'react'
|
||||
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
|
||||
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
|
||||
import {
|
||||
flushRegistrationSuccess,
|
||||
getRegistrationSuccessSnapshot,
|
||||
subscribeRegistrationSuccess,
|
||||
} from '@/app/components/base/amplitude/registration-tracking'
|
||||
import { useAmplitudeInitialized } from '@/app/components/base/amplitude/use-amplitude-initialized'
|
||||
import { useAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store'
|
||||
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
|
||||
@ -43,13 +47,18 @@ function buildAmplitudeProperties({
|
||||
return properties
|
||||
}
|
||||
|
||||
function AmplitudeIdentitySync() {
|
||||
export function AmplitudeIdentitySync() {
|
||||
const { data: userProfile } = useSuspenseQuery({
|
||||
...userProfileQueryOptions(),
|
||||
select: (data) => data.profile,
|
||||
})
|
||||
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
|
||||
const lastIdentityRef = useRef<string | undefined>(undefined)
|
||||
const registrationSnapshot = useSyncExternalStore(
|
||||
subscribeRegistrationSuccess,
|
||||
getRegistrationSuccessSnapshot,
|
||||
getRegistrationSuccessSnapshot,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!userProfile.id) return
|
||||
@ -63,13 +72,14 @@ function AmplitudeIdentitySync() {
|
||||
properties,
|
||||
})
|
||||
|
||||
if (identity === lastIdentityRef.current) return
|
||||
if (identity !== lastIdentityRef.current) {
|
||||
setUserId(userProfile.email)
|
||||
setUserProperties(properties)
|
||||
lastIdentityRef.current = identity
|
||||
}
|
||||
|
||||
setUserId(userProfile.email)
|
||||
setUserProperties(properties)
|
||||
flushRegistrationSuccess()
|
||||
lastIdentityRef.current = identity
|
||||
}, [currentWorkspace, userProfile])
|
||||
void flushRegistrationSuccess()
|
||||
}, [currentWorkspace, registrationSnapshot, userProfile])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@ -1,12 +1,31 @@
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import Cookies from 'js-cookie'
|
||||
import { StrictMode } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import { OAuthRegistrationAnalytics } from '../oauth-registration-analytics'
|
||||
|
||||
const { mockSendGAEvent, mockRememberRegistrationSuccess } = vi.hoisted(() => ({
|
||||
mockSendGAEvent: vi.fn(),
|
||||
const {
|
||||
mockConsent,
|
||||
mockNormalizeRegistrationAttribution,
|
||||
mockRememberRegistrationSuccess,
|
||||
mockSendGAEvent,
|
||||
} = vi.hoisted(() => ({
|
||||
mockConsent: { value: 'granted' as 'unknown' | 'denied' | 'granted' | 'disabled' },
|
||||
mockNormalizeRegistrationAttribution: vi.fn((value: Record<string, unknown> | null) => {
|
||||
if (!value) return null
|
||||
const allowed = Object.fromEntries(
|
||||
Object.entries(value).filter(
|
||||
([key, item]) =>
|
||||
['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'slug'].includes(
|
||||
key,
|
||||
) && typeof item === 'string',
|
||||
),
|
||||
)
|
||||
return Object.keys(allowed).length ? allowed : null
|
||||
}),
|
||||
mockRememberRegistrationSuccess: vi.fn(),
|
||||
mockSendGAEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/gtag', () => ({
|
||||
@ -17,7 +36,14 @@ vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../base/analytics-consent/consent-store', () => ({
|
||||
useAnalyticsConsent: () => mockConsent.value,
|
||||
}))
|
||||
|
||||
vi.mock('../base/amplitude/registration-tracking', () => ({
|
||||
normalizeRegistrationAttribution: (
|
||||
...args: Parameters<typeof mockNormalizeRegistrationAttribution>
|
||||
) => mockNormalizeRegistrationAttribution(...args),
|
||||
rememberRegistrationSuccess: (...args: unknown[]) => mockRememberRegistrationSuccess(...args),
|
||||
}))
|
||||
|
||||
@ -33,22 +59,74 @@ const setSearchParams = (searchParams = '') => {
|
||||
describe('OAuthRegistrationAnalytics', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.sessionStorage.clear()
|
||||
mockConsent.value = 'granted'
|
||||
mockRememberRegistrationSuccess.mockReturnValue(true)
|
||||
Cookies.remove('utm_info')
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
setSearchParams()
|
||||
})
|
||||
|
||||
it('should track oauth registration with utm info and clear the query flag', async () => {
|
||||
it('queues the Amplitude marker while consent is unknown and cleans the URL after persist', async () => {
|
||||
mockConsent.value = 'unknown'
|
||||
Cookies.set('utm_info', JSON.stringify({ utm_source: 'linkedin', slug: 'agent-launch' }))
|
||||
setSearchParams('oauth_new_user=true&source=signin')
|
||||
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRememberRegistrationSuccess).toHaveBeenCalledWith({
|
||||
method: 'oauth',
|
||||
utmInfo: { utm_source: 'linkedin', slug: 'agent-launch' },
|
||||
})
|
||||
})
|
||||
expect(mockSendGAEvent).toHaveBeenCalledTimes(1)
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
expect(window.location.search).toBe('?source=signin')
|
||||
})
|
||||
|
||||
it('keeps the recoverable OAuth signal when marker persistence fails', () => {
|
||||
Cookies.set('utm_info', JSON.stringify({ utm_source: 'linkedin' }))
|
||||
setSearchParams('oauth_new_user=true&source=signin')
|
||||
mockRememberRegistrationSuccess.mockReturnValue(false)
|
||||
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(window.location.search).toBe('?oauth_new_user=true&source=signin')
|
||||
expect(Cookies.get('utm_info')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the OAuth marker while consent is unknown, then cleans without a second Amplitude queue on denial', async () => {
|
||||
mockConsent.value = 'unknown'
|
||||
Cookies.set('utm_info', JSON.stringify({ utm_source: 'blog' }))
|
||||
setSearchParams('oauth_new_user=true')
|
||||
|
||||
const { rerender } = render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1))
|
||||
expect(mockSendGAEvent).toHaveBeenCalledTimes(1)
|
||||
|
||||
mockConsent.value = 'denied'
|
||||
rerender(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => expect(window.location.search).toBe(''))
|
||||
expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(mockSendGAEvent).toHaveBeenCalledTimes(1)
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('queues immediately with pre-granted consent and keeps only allowlisted UTM fields', async () => {
|
||||
Cookies.set(
|
||||
'utm_info',
|
||||
JSON.stringify({
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
arbitrary: 'discard-me',
|
||||
utm_term: { nested: true },
|
||||
}),
|
||||
)
|
||||
|
||||
setSearchParams('oauth_new_user=true&source=signin')
|
||||
const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
|
||||
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
@ -64,16 +142,13 @@ describe('OAuthRegistrationAnalytics', () => {
|
||||
slug: 'agent-launch',
|
||||
})
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin?source=signin')
|
||||
})
|
||||
expect(window.location.search).toBe('?source=signin')
|
||||
})
|
||||
|
||||
it('should fall back to the base registration event when the utm cookie is invalid', async () => {
|
||||
it('uses the base event and cleans up when the UTM cookie is malformed', async () => {
|
||||
Cookies.set('utm_info', '{invalid-json')
|
||||
|
||||
setSearchParams('oauth_new_user=true')
|
||||
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => {
|
||||
@ -89,23 +164,77 @@ describe('OAuthRegistrationAnalytics', () => {
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should do nothing without the oauth registration query flag', () => {
|
||||
it('cleans a false OAuth marker immediately without tracking or clearing utm_info', async () => {
|
||||
mockConsent.value = 'unknown'
|
||||
Cookies.set('utm_info', JSON.stringify({ utm_source: 'blog' }))
|
||||
setSearchParams('oauth_new_user=false')
|
||||
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => expect(window.location.search).toBe(''))
|
||||
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
|
||||
expect(mockSendGAEvent).not.toHaveBeenCalled()
|
||||
expect(Cookies.get('utm_info')).toBe(JSON.stringify({ utm_source: 'blog' }))
|
||||
})
|
||||
|
||||
it('tracks GA and Amplitude once across StrictMode effects and rerenders', async () => {
|
||||
setSearchParams('oauth_new_user=true')
|
||||
|
||||
const { rerender } = render(
|
||||
<StrictMode>
|
||||
<OAuthRegistrationAnalytics />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<StrictMode>
|
||||
<OAuthRegistrationAnalytics />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1))
|
||||
expect(mockSendGAEvent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('tracks GA once across an unknown-consent remount that simulates reload', async () => {
|
||||
mockConsent.value = 'unknown'
|
||||
setSearchParams('oauth_new_user=true')
|
||||
|
||||
const firstRender = render(<OAuthRegistrationAnalytics />)
|
||||
await waitFor(() => expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1))
|
||||
firstRender.unmount()
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
expect(mockSendGAEvent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('treats analytics-disabled consent as terminal and cleans without Amplitude', async () => {
|
||||
mockConsent.value = 'disabled'
|
||||
Cookies.set('utm_info', JSON.stringify({ utm_source: 'blog' }))
|
||||
setSearchParams('oauth_new_user=true')
|
||||
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => expect(window.location.search).toBe(''))
|
||||
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does nothing without the OAuth registration query marker', () => {
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
|
||||
expect(mockSendGAEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear a false oauth registration query flag without tracking', async () => {
|
||||
setSearchParams('oauth_new_user=false')
|
||||
const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
|
||||
it('clears an abandoned flow guard so a later OAuth registration can emit GA', () => {
|
||||
window.sessionStorage.setItem('oauth_registration_ga_sent', 'true')
|
||||
const abandonedFlow = render(<OAuthRegistrationAnalytics />)
|
||||
abandonedFlow.unmount()
|
||||
|
||||
setSearchParams('oauth_new_user=true')
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin')
|
||||
})
|
||||
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
|
||||
expect(mockSendGAEvent).not.toHaveBeenCalled()
|
||||
expect(mockSendGAEvent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -186,7 +186,10 @@ describe('AppDetailSection', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render access point navigation using its app route', () => {
|
||||
it('should render access point navigation when access point permission is granted', () => {
|
||||
// Arrange
|
||||
mockAppPermissionKeys = [AppACLPermission.AccessPoint]
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
|
||||
@ -200,6 +203,19 @@ describe('AppDetailSection', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide access point navigation when access point permission is missing', () => {
|
||||
// Arrange
|
||||
mockAppPermissionKeys = [AppACLPermission.Monitor]
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'common.appMenus.accessPoint' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render deploy navigation with app deploy ACL regardless of the legacy workspace role', () => {
|
||||
// Arrange
|
||||
mockAppMode = 'workflow'
|
||||
|
||||
@ -120,12 +120,16 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: t(($) => $['appMenus.accessPoint'], { ns: 'common' }),
|
||||
href: `/app/${appId}/access-point`,
|
||||
icon: accessPointNavIcon,
|
||||
selectedIcon: accessPointNavIcon,
|
||||
},
|
||||
...(appACLCapabilities.canAccessPoint
|
||||
? [
|
||||
{
|
||||
name: t(($) => $['appMenus.accessPoint'], { ns: 'common' }),
|
||||
href: `/app/${appId}/access-point`,
|
||||
icon: accessPointNavIcon,
|
||||
selectedIcon: accessPointNavIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(supportsAppDeploy && appACLCapabilities.canDeploy
|
||||
? [
|
||||
{
|
||||
|
||||
@ -295,6 +295,7 @@ function renderFlow(
|
||||
return render(
|
||||
<PublisherEnvironmentFlow
|
||||
appId="app-1"
|
||||
canAccessPoint
|
||||
deployment={deployment}
|
||||
environmentId={deployment.environment.id}
|
||||
environmentName={deployment.environment.display_name}
|
||||
@ -334,6 +335,7 @@ function renderFlowWithPolling(deployment = createDeployment()) {
|
||||
<PublisherPollingObserver />
|
||||
<PublisherEnvironmentFlow
|
||||
appId="app-1"
|
||||
canAccessPoint
|
||||
deployment={deployment}
|
||||
environmentId={deployment.environment.id}
|
||||
environmentName={deployment.environment.display_name}
|
||||
|
||||
@ -6,6 +6,7 @@ import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { PublisherActionsSection } from '../built-in-publisher/actions-section'
|
||||
import { PublisherSummarySection } from '../built-in-publisher/summary-section'
|
||||
import { PublisherEnvironmentActionsSection } from '../environment-deployment-flow/actions-section'
|
||||
|
||||
vi.mock('../publish-with-multiple-model', () => ({
|
||||
default: ({
|
||||
@ -314,6 +315,7 @@ describe('app-publisher sections', () => {
|
||||
description: 'Workflow description',
|
||||
}}
|
||||
appURL="https://example.com/app"
|
||||
canAccessPoint
|
||||
disabledFunctionButton={false}
|
||||
disabledFunctionTooltip="disabled"
|
||||
handleOpenRunConfig={handleOpenRunConfig}
|
||||
@ -494,6 +496,7 @@ describe('app-publisher sections', () => {
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}}
|
||||
appURL="https://example.com/app"
|
||||
canAccessPoint
|
||||
disabledFunctionButton={false}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode
|
||||
@ -517,11 +520,49 @@ describe('app-publisher sections', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should hide the built-in Access Point action without permission', () => {
|
||||
render(
|
||||
<PublisherActionsSection
|
||||
appDetail={{ id: 'workflow-app', mode: AppModeEnum.WORKFLOW }}
|
||||
appURL="https://example.com/app"
|
||||
canAccessPoint={false}
|
||||
disabledFunctionButton={false}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode
|
||||
publishedAt={Date.now()}
|
||||
showDeployAction
|
||||
workflowToolAvailable
|
||||
workflowToolIsLoading={false}
|
||||
onConfigureWorkflowTool={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/(?:^|\.)appMenus\.accessPoint(?=$|:)/)).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /appMenus\.deploy\b/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/workflow-app/deploy',
|
||||
)
|
||||
})
|
||||
|
||||
it('should hide the environment Access Point action without permission', () => {
|
||||
render(
|
||||
<PublisherEnvironmentActionsSection
|
||||
appId="workflow-app"
|
||||
canAccessPoint={false}
|
||||
environmentId="staging"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/(?:^|\.)appMenus\.accessPoint(?=$|:)/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/(?:^|\.)appMenus\.deploy(?=$|:)/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expose unavailable quick links as disabled buttons before the first publish', () => {
|
||||
render(
|
||||
<PublisherActionsSection
|
||||
appDetail={{ id: 'workflow-app', mode: AppModeEnum.WORKFLOW }}
|
||||
appURL="https://example.com/app"
|
||||
canAccessPoint
|
||||
disabledFunctionButton
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
|
||||
@ -23,6 +23,7 @@ type PublisherActionsSectionProps = Pick<
|
||||
| null
|
||||
| undefined
|
||||
appURL: string
|
||||
canAccessPoint?: boolean
|
||||
disabledFunctionButton: boolean
|
||||
disabledFunctionTooltip?: string
|
||||
handleOpenRunConfig?: (url: string) => void
|
||||
@ -41,6 +42,7 @@ type PublisherActionsSectionProps = Pick<
|
||||
export function PublisherActionsSection({
|
||||
appDetail,
|
||||
appURL,
|
||||
canAccessPoint = false,
|
||||
disabledFunctionButton,
|
||||
disabledFunctionTooltip,
|
||||
handleOpenRunConfig,
|
||||
@ -114,14 +116,16 @@ export function PublisherActionsSection({
|
||||
<TooltipContent role="tooltip">{disabledFunctionTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<SuggestedAction
|
||||
disabled={navigationDisabled}
|
||||
description={t(($) => $['common.accessPointDescription'], { ns: 'workflow' })}
|
||||
link={appId ? `/app/${appId}/access-point` : undefined}
|
||||
icon={<span className="i-custom-vender-agent-v2-access-point size-4" />}
|
||||
>
|
||||
{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}
|
||||
</SuggestedAction>
|
||||
{canAccessPoint && (
|
||||
<SuggestedAction
|
||||
disabled={navigationDisabled}
|
||||
description={t(($) => $['common.accessPointDescription'], { ns: 'workflow' })}
|
||||
link={appId ? `/app/${appId}/access-point` : undefined}
|
||||
icon={<span className="i-custom-vender-agent-v2-access-point size-4" />}
|
||||
>
|
||||
{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}
|
||||
</SuggestedAction>
|
||||
)}
|
||||
{showDeploy && (
|
||||
<SuggestedAction
|
||||
disabled={navigationDisabled}
|
||||
|
||||
@ -8,10 +8,12 @@ function environmentHref(path: string, appId: string, environmentId: string) {
|
||||
|
||||
export function PublisherEnvironmentActionsSection({
|
||||
appId,
|
||||
canAccessPoint = false,
|
||||
deployment,
|
||||
environmentId,
|
||||
}: {
|
||||
appId?: string
|
||||
canAccessPoint?: boolean
|
||||
deployment?: EnvironmentDeployment
|
||||
environmentId: string
|
||||
}) {
|
||||
@ -22,14 +24,16 @@ export function PublisherEnvironmentActionsSection({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border-t-[0.5px] border-t-divider-regular p-3">
|
||||
<SuggestedAction
|
||||
disabled={actionsDisabled}
|
||||
description={t(($) => $['common.accessPointDescription'], { ns: 'workflow' })}
|
||||
link={accessPointHref}
|
||||
icon={<span className="i-custom-vender-agent-v2-access-point size-4" />}
|
||||
>
|
||||
{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}
|
||||
</SuggestedAction>
|
||||
{canAccessPoint && (
|
||||
<SuggestedAction
|
||||
disabled={actionsDisabled}
|
||||
description={t(($) => $['common.accessPointDescription'], { ns: 'workflow' })}
|
||||
link={accessPointHref}
|
||||
icon={<span className="i-custom-vender-agent-v2-access-point size-4" />}
|
||||
>
|
||||
{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}
|
||||
</SuggestedAction>
|
||||
)}
|
||||
<SuggestedAction
|
||||
disabled={actionsDisabled}
|
||||
description={t(($) => $['common.deployDescription'], { ns: 'workflow' })}
|
||||
|
||||
@ -15,6 +15,7 @@ import { PublisherEnvironmentSummarySection } from './summary-section'
|
||||
|
||||
type PublisherEnvironmentFlowProps = {
|
||||
appId?: string
|
||||
canAccessPoint?: boolean
|
||||
deployment?: EnvironmentDeployment
|
||||
environmentId: string
|
||||
environmentName: string
|
||||
@ -28,6 +29,7 @@ type PublisherEnvironmentFlowProps = {
|
||||
|
||||
export function PublisherEnvironmentFlow({
|
||||
appId,
|
||||
canAccessPoint = false,
|
||||
deployment,
|
||||
environmentId,
|
||||
environmentName,
|
||||
@ -91,6 +93,7 @@ export function PublisherEnvironmentFlow({
|
||||
/>
|
||||
<PublisherEnvironmentActionsSection
|
||||
appId={appId}
|
||||
canAccessPoint={canAccessPoint}
|
||||
deployment={deployment}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
|
||||
@ -17,12 +17,13 @@ export function AppPublisher(props: AppPublisherProps) {
|
||||
select: (data) => data.profile.id,
|
||||
})
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const canDeploy = getAppACLCapabilities(appDetail?.permission_keys, {
|
||||
const appACLCapabilities = getAppACLCapabilities(appDetail?.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: appDetail?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
}).canDeploy
|
||||
const supportsMultiEnvironment = appDetail?.mode === AppModeEnum.WORKFLOW && canDeploy
|
||||
})
|
||||
const supportsMultiEnvironment =
|
||||
appDetail?.mode === AppModeEnum.WORKFLOW && appACLCapabilities.canDeploy
|
||||
|
||||
return (
|
||||
<AppPublisherStateBoundary
|
||||
@ -31,6 +32,7 @@ export function AppPublisher(props: AppPublisherProps) {
|
||||
>
|
||||
<PublisherContent
|
||||
{...props}
|
||||
canAccessPoint={appACLCapabilities.canAccessPoint}
|
||||
open={open}
|
||||
supportsMultiEnvironment={supportsMultiEnvironment}
|
||||
onOpenStateChange={setOpen}
|
||||
|
||||
@ -33,12 +33,14 @@ import { useWorkflowLaunch } from './use-workflow-launch'
|
||||
import { useWorkflowTool } from './use-workflow-tool'
|
||||
|
||||
type PublisherContentProps = AppPublisherProps & {
|
||||
canAccessPoint: boolean
|
||||
open: boolean
|
||||
supportsMultiEnvironment: boolean
|
||||
onOpenStateChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function PublisherContent({
|
||||
canAccessPoint,
|
||||
crossAxisOffset = 0,
|
||||
debugWithMultipleModel = false,
|
||||
disabled = false,
|
||||
@ -212,6 +214,7 @@ export function PublisherContent({
|
||||
actions: {
|
||||
appDetail,
|
||||
appURL,
|
||||
canAccessPoint,
|
||||
disabledFunctionButton,
|
||||
disabledFunctionTooltip,
|
||||
handleOpenRunConfig: workflowLaunch.openDialog,
|
||||
@ -236,6 +239,7 @@ export function PublisherContent({
|
||||
disabled={disabled}
|
||||
environmentPublisher={{
|
||||
appId: appDetail?.id,
|
||||
canAccessPoint,
|
||||
deployment: selectedEnvironmentDeployment,
|
||||
environmentId: selectedEnvironmentId,
|
||||
environmentName:
|
||||
|
||||
@ -650,7 +650,7 @@ function render(
|
||||
return renderWithConsoleQuery(ui, { queryClient })
|
||||
}
|
||||
|
||||
let appPermissionKeys: string[] = [AppACLPermission.Deploy]
|
||||
let appPermissionKeys: string[] = [AppACLPermission.AccessPoint, AppACLPermission.Deploy]
|
||||
let appDetailAvailable = true
|
||||
const mockConsoleState = vi.hoisted(() => ({
|
||||
workspacePermissionKeys: [] as string[],
|
||||
@ -744,7 +744,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
describe('AppDeploy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
appPermissionKeys = [AppACLPermission.Deploy]
|
||||
appPermissionKeys = [AppACLPermission.AccessPoint, AppACLPermission.Deploy]
|
||||
appDetailAvailable = true
|
||||
mockBuiltInEnvironment.appDetail.enable_api = false
|
||||
mockBuiltInEnvironment.appDetail.enable_site = true
|
||||
@ -815,6 +815,18 @@ describe('AppDeploy', () => {
|
||||
).toHaveAttribute('href', '/app/app-1/access-point?environment=canary&accessPoint=serviceApi')
|
||||
})
|
||||
|
||||
it('keeps active access points non-navigable without access point permission', () => {
|
||||
appPermissionKeys = [AppACLPermission.Deploy]
|
||||
|
||||
render(<AppDeploy />)
|
||||
|
||||
const canaryRow = within(screen.getByRole('row', { name: /Canary/ }))
|
||||
const webAppLabel =
|
||||
'agentV2.agentDetail.access.webApp.title · agentV2.agentDetail.access.status.inService'
|
||||
expect(canaryRow.queryByRole('link', { name: webAppLabel })).not.toBeInTheDocument()
|
||||
expect(canaryRow.getByRole('button', { name: webAppLabel })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('renders the built-in version, access points, and publisher from live app data', () => {
|
||||
render(<AppDeploy />)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user