Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4743360620 | ||
|
|
33a61e32ed | ||
|
|
2a7968d210 | ||
|
|
2f7480c900 | ||
|
|
037b7f6b5f | ||
|
|
3e6c3ace7b | ||
|
|
f26812310f | ||
|
|
0a9f1d58bd | ||
|
|
a1a2abb533 | ||
|
|
3f3422c37f | ||
|
|
ccbb556a8b | ||
|
|
ad2094c96f | ||
|
|
0f77b623d8 | ||
|
|
87e7e1ca2d | ||
|
|
796328e9f2 | ||
|
|
526059faeb | ||
|
|
272bdeb555 |
@@ -729,7 +729,6 @@ OTEL_MAX_EXPORT_BATCH_SIZE=512
|
||||
OTEL_METRIC_EXPORT_INTERVAL=60000
|
||||
OTEL_BATCH_EXPORT_TIMEOUT=10000
|
||||
OTEL_METRIC_EXPORT_TIMEOUT=30000
|
||||
|
||||
# Prevent Clickjacking
|
||||
ALLOW_EMBED=false
|
||||
|
||||
|
||||
@@ -816,6 +816,41 @@ class UpdateConfig(BaseSettings):
|
||||
)
|
||||
|
||||
|
||||
class CommunityTelemetryConfig(BaseSettings):
|
||||
"""
|
||||
Configuration for anonymous self-hosted community telemetry.
|
||||
"""
|
||||
|
||||
DISABLE_TELEMETRY: bool = Field(
|
||||
description="Disable anonymous community telemetry",
|
||||
default=False,
|
||||
)
|
||||
DO_NOT_TRACK: bool = Field(
|
||||
description="Respect the standard do-not-track opt-out signal for telemetry",
|
||||
default=False,
|
||||
)
|
||||
TELEMETRY_ENDPOINT: str = Field(
|
||||
description="Endpoint for anonymous community telemetry events",
|
||||
default="https://otel.dify.ai/v1/events",
|
||||
)
|
||||
TELEMETRY_FALLBACK_ENDPOINT: str = Field(
|
||||
description="Fallback endpoint for anonymous community telemetry events",
|
||||
default="https://otel.dify.cn/v1/events",
|
||||
)
|
||||
TELEMETRY_TIMEOUT_SECONDS: PositiveInt = Field(
|
||||
description="HTTP timeout in seconds for anonymous community telemetry requests",
|
||||
default=3,
|
||||
)
|
||||
TELEMETRY_HEARTBEAT_INTERVAL_MINUTES: PositiveInt = Field(
|
||||
description="Celery beat interval in minutes for checking whether heartbeat telemetry is due",
|
||||
default=30,
|
||||
)
|
||||
CI: bool = Field(
|
||||
description="Whether the process is running in CI; telemetry is skipped when true",
|
||||
default=False,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowVariableTruncationConfig(BaseSettings):
|
||||
WORKFLOW_VARIABLE_TRUNCATION_MAX_SIZE: PositiveInt = Field(
|
||||
# 1000 KiB
|
||||
@@ -1497,11 +1532,6 @@ class LoginConfig(BaseSettings):
|
||||
|
||||
|
||||
class AccountConfig(BaseSettings):
|
||||
ENABLE_CHANGE_EMAIL: bool = Field(
|
||||
description="whether users can change their email address",
|
||||
default=True,
|
||||
)
|
||||
|
||||
ACCOUNT_DELETION_TOKEN_EXPIRY_MINUTES: PositiveInt = Field(
|
||||
description="Duration in minutes for which a account deletion token remains valid",
|
||||
default=5,
|
||||
@@ -1599,6 +1629,7 @@ class FeatureConfig(
|
||||
TenantIsolatedTaskQueueConfig,
|
||||
ToolConfig,
|
||||
UpdateConfig,
|
||||
CommunityTelemetryConfig,
|
||||
WorkflowConfig,
|
||||
WorkflowNodeExecutionConfig,
|
||||
WorkspaceConfig,
|
||||
|
||||
@@ -10,7 +10,6 @@ from extensions.ext_database import db
|
||||
from libs.login import current_account_with_tenant
|
||||
from models.dataset import Dataset
|
||||
from models.model import App
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
from services.enterprise.rbac_service import RBACService
|
||||
|
||||
__all__ = ["RBACPermission", "RBACResourceScope", "enforce_rbac_access", "rbac_permission_required"]
|
||||
@@ -52,7 +51,7 @@ def enforce_rbac_access(
|
||||
check_resource_type = None if resource_type == RBACResourceScope.WORKSPACE else resource_type
|
||||
resource_id = None
|
||||
if resource_required and check_resource_type:
|
||||
resource_id = _extract_resource_id(resource_type, tenant_id, path_args)
|
||||
resource_id = _extract_resource_id(resource_type, path_args)
|
||||
if _is_resource_owned_by_current_user(tenant_id, account_id, resource_type, resource_id):
|
||||
return
|
||||
allowed = RBACService.CheckAccess.check(
|
||||
@@ -132,14 +131,11 @@ def _is_resource_owned_by_current_user(
|
||||
return False
|
||||
|
||||
|
||||
def _extract_resource_id(
|
||||
resource_type: RBACResourceScope, tenant_id: str, path_args: dict[str, object] | None = None
|
||||
) -> str:
|
||||
def _extract_resource_id(resource_type: RBACResourceScope, path_args: dict[str, object] | None = None) -> str:
|
||||
"""Extract the resource ID from matched path arguments.
|
||||
|
||||
Some legacy route classes use neutral names such as ``resource_id`` for
|
||||
app/dataset resources, and Agent routes carry ``agent_id``, which is
|
||||
resolved to the App backing that Agent.
|
||||
app/dataset resources, and Agent App routes use ``agent_id`` as the app id.
|
||||
Dataset endpoints behind a rag-pipeline route contain ``pipeline_id``
|
||||
instead of ``dataset_id``. In that case we look up the associated
|
||||
``Dataset`` row via ``Dataset.pipeline_id``.
|
||||
@@ -150,19 +146,10 @@ def _extract_resource_id(
|
||||
matched_args = {**view_args, **(path_args or {})}
|
||||
|
||||
if resource_type == RBACResourceScope.APP:
|
||||
app_id = matched_args.get("app_id")
|
||||
if app_id:
|
||||
return str(app_id)
|
||||
|
||||
agent_id = matched_args.get("agent_id")
|
||||
if agent_id:
|
||||
authz_app_id = AgentRosterService(db.session).peek_authz_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
return authz_app_id or str(agent_id)
|
||||
|
||||
resource_id = matched_args.get("resource_id")
|
||||
if resource_id:
|
||||
return str(resource_id)
|
||||
raise ValueError("Missing app_id in request path")
|
||||
app_id = matched_args.get("app_id") or matched_args.get("agent_id") or matched_args.get("resource_id")
|
||||
if not app_id:
|
||||
raise ValueError("Missing app_id in request path")
|
||||
return str(app_id)
|
||||
|
||||
if resource_type == RBACResourceScope.DATASET:
|
||||
dataset_id = matched_args.get("dataset_id") or matched_args.get("resource_id")
|
||||
|
||||
@@ -230,7 +230,6 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -440,7 +439,6 @@ class SnippetAgentComposerSaveToRosterApi(Resource):
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -480,7 +478,6 @@ class AgentComposerApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
|
||||
@@ -552,7 +552,6 @@ class AgentAppListApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -590,7 +589,6 @@ class AgentAppListApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -632,7 +630,6 @@ class AgentAppApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -658,7 +655,6 @@ class AgentAppApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def delete(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
@@ -716,7 +712,6 @@ class AgentPublishApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -739,7 +734,6 @@ class AgentBuildDraftCheckoutApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -816,7 +810,6 @@ class AgentBuildDraftApplyApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -839,7 +832,6 @@ class AgentAppCopyApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -865,7 +857,6 @@ class AgentApiAccessApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
@@ -882,7 +873,6 @@ class AgentApiStatusApi(Resource):
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -901,7 +891,6 @@ class AgentApiKeyListApi(BaseApiKeyListResource):
|
||||
token_prefix = "app-"
|
||||
|
||||
@console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__])
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID) -> dict[str, object]:
|
||||
@@ -912,7 +901,6 @@ class AgentApiKeyListApi(BaseApiKeyListResource):
|
||||
@console_ns.response(400, "Maximum keys exceeded")
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str, agent_id: UUID) -> tuple[dict[str, object], int]:
|
||||
@@ -932,7 +920,6 @@ class AgentApiKeyApi(BaseApiKeyResource):
|
||||
@console_ns.response(204, "Agent service API key deleted")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@with_session
|
||||
def delete(
|
||||
@@ -978,7 +965,6 @@ class AgentLogsApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
@@ -1017,7 +1003,6 @@ class AgentLogMessagesApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
@@ -1056,7 +1041,6 @@ class AgentLogSourcesApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
@@ -1077,7 +1061,6 @@ class AgentStatisticsSummaryApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
@@ -1103,7 +1086,6 @@ class AgentRosterVersionsApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
@@ -1119,7 +1101,6 @@ class AgentRosterVersionDetailApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID, version_id: UUID):
|
||||
@@ -1140,7 +1121,6 @@ class AgentRosterVersionRestoreApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
|
||||
@@ -12,7 +12,6 @@ from werkzeug.exceptions import Forbidden
|
||||
from configs import dify_config
|
||||
from controllers.common.schema import register_response_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console.app.wraps import agent_manage_required_for_agent_app
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
@@ -195,7 +194,6 @@ class AppApiKeyListResource(BaseApiKeyListResource):
|
||||
@console_ns.doc(params={"resource_id": "App ID"})
|
||||
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
|
||||
@with_current_tenant_id
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
|
||||
"""Get all API keys for an app"""
|
||||
@@ -212,7 +210,6 @@ class AppApiKeyListResource(BaseApiKeyListResource):
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
def post(self, session: Session, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]:
|
||||
"""Create a new API key for an app"""
|
||||
@@ -236,7 +233,6 @@ class AppApiKeyResource(BaseApiKeyResource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
def delete(
|
||||
self,
|
||||
|
||||
@@ -9,7 +9,7 @@ from flask_restx import Resource
|
||||
from pydantic import AliasChoices, BaseModel, Field, ValidationInfo, computed_field, field_validator, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.app_access import resolve_app_access_filter
|
||||
@@ -23,7 +23,7 @@ from controllers.common.schema import (
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model, with_session
|
||||
from controllers.console.app.wraps import get_app_model, with_session
|
||||
from controllers.console.workspace.models import LoadBalancingPayload
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
@@ -75,7 +75,6 @@ from services.entities.knowledge_entities.knowledge_entities import (
|
||||
WeightModel,
|
||||
WeightVectorSetting,
|
||||
)
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.feature_service import FeatureService
|
||||
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
|
||||
|
||||
@@ -828,7 +827,6 @@ class AppApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def put(self, session: Session, app_model: App):
|
||||
@@ -863,7 +861,6 @@ class AppApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_DELETE)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
@get_app_model
|
||||
def delete(self, session: Session, app_model: App):
|
||||
@@ -888,7 +885,6 @@ class AppCopyApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@get_app_model(mode=None)
|
||||
@@ -900,19 +896,16 @@ class AppCopyApi(Resource):
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
import_service = AppDslService(session)
|
||||
yaml_content = import_service.export_dsl(app_model=app_model, session=session, include_secret=True)
|
||||
try:
|
||||
result = import_service.import_app(
|
||||
account=current_user,
|
||||
import_mode=ImportMode.YAML_CONTENT,
|
||||
yaml_content=yaml_content,
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
icon_type=args.icon_type,
|
||||
icon=args.icon,
|
||||
icon_background=args.icon_background,
|
||||
)
|
||||
except NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
result = import_service.import_app(
|
||||
account=current_user,
|
||||
import_mode=ImportMode.YAML_CONTENT,
|
||||
yaml_content=yaml_content,
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
icon_type=args.icon_type,
|
||||
icon=args.icon,
|
||||
icon_background=args.icon_background,
|
||||
)
|
||||
if result.status == ImportStatus.FAILED:
|
||||
session.rollback()
|
||||
return dump_response(AppImportResponse, result), 400
|
||||
@@ -966,7 +959,6 @@ class AppExportApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL)
|
||||
@agent_manage_required_for_agent_app
|
||||
@get_app_model
|
||||
def get(self, app_model: App):
|
||||
"""Export app"""
|
||||
@@ -991,7 +983,6 @@ class AppPublishToCreatorsPlatformApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_current_user_id
|
||||
@get_app_model(mode=None)
|
||||
def post(self, current_user_id: str, app_model: App):
|
||||
@@ -1022,7 +1013,6 @@ class AppNameApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def post(self, session: Session, app_model: App):
|
||||
@@ -1050,7 +1040,6 @@ class AppIconApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def post(self, session: Session, app_model: App):
|
||||
@@ -1084,7 +1073,6 @@ class AppSiteStatus(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def post(self, session: Session, app_model: App):
|
||||
@@ -1112,7 +1100,6 @@ class AppApiStatus(Resource):
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def post(self, session: Session, app_model: App):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.schema import register_enum_models, register_schema_models
|
||||
@@ -29,7 +28,6 @@ from services.app_dsl_service import (
|
||||
)
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
from .. import console_ns
|
||||
@@ -93,21 +91,18 @@ class AppImportApi(Resource):
|
||||
import_service = AppDslService(session)
|
||||
# Import app
|
||||
account = current_user
|
||||
try:
|
||||
result = import_service.import_app(
|
||||
account=account,
|
||||
import_mode=args.mode,
|
||||
yaml_content=args.yaml_content,
|
||||
yaml_url=args.yaml_url,
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
icon_type=args.icon_type,
|
||||
icon=args.icon,
|
||||
icon_background=args.icon_background,
|
||||
app_id=args.app_id,
|
||||
)
|
||||
except NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
result = import_service.import_app(
|
||||
account=account,
|
||||
import_mode=args.mode,
|
||||
yaml_content=args.yaml_content,
|
||||
yaml_url=args.yaml_url,
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
icon_type=args.icon_type,
|
||||
icon=args.icon,
|
||||
icon_background=args.icon_background,
|
||||
app_id=args.app_id,
|
||||
)
|
||||
if result.status == ImportStatus.FAILED:
|
||||
session.rollback()
|
||||
else:
|
||||
@@ -162,10 +157,7 @@ class AppImportConfirmApi(Resource):
|
||||
import_service = AppDslService(session)
|
||||
# Confirm import
|
||||
account = current_user
|
||||
try:
|
||||
result = import_service.confirm_import(import_id=import_id, account=account)
|
||||
except NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
result = import_service.confirm_import(import_id=import_id, account=account)
|
||||
if result.status == ImportStatus.FAILED:
|
||||
session.rollback()
|
||||
else:
|
||||
|
||||
@@ -10,7 +10,7 @@ from constants.languages import supported_language
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
@@ -93,7 +93,6 @@ class AppSite(Resource):
|
||||
@login_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
@@ -146,7 +145,6 @@ class AppSiteAccessTokenReset(Resource):
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
|
||||
@@ -12,22 +12,14 @@ from typing import cast, overload
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.session import with_session
|
||||
from controllers.common.wraps import RBACPermission, RBACResourceScope, enforce_rbac_access
|
||||
from controllers.console.app.error import AppNotFoundError
|
||||
from extensions.ext_database import db
|
||||
from libs.login import current_account_with_tenant
|
||||
from models import App, AppMode, TrialApp
|
||||
from models.agent import AgentScope
|
||||
from services.recommended_app_service import RecommendedAppService
|
||||
|
||||
__all__ = [
|
||||
"agent_manage_required_for_agent_app",
|
||||
"get_app_model",
|
||||
"get_app_model_with_trial",
|
||||
"with_session",
|
||||
]
|
||||
__all__ = ["get_app_model", "get_app_model_with_trial", "with_session"]
|
||||
|
||||
|
||||
def _load_app_model(session: Session, app_id: str) -> App | None:
|
||||
@@ -56,45 +48,6 @@ def _load_app_model_with_trial(session: Session, app_id: str) -> App | None:
|
||||
return app_model
|
||||
|
||||
|
||||
def agent_manage_required_for_agent_app[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
"""Gate generic app management routes that target an Agent App.
|
||||
|
||||
A hidden workflow-only backing App only reuses the App runtime and is not
|
||||
part of the general app management plane, so generic routes reject it
|
||||
outright. Managing a roster Agent App mutates the roster Agent behind it
|
||||
(rename/icon sync, archive, API enablement), so it additionally requires
|
||||
workspace ``agent.manage`` on top of the route's existing App permission
|
||||
checks when RBAC is enabled. A no-op for non-agent Apps. Must be placed
|
||||
above ``get_app_model`` so the ``app_id`` path parameter is still present.
|
||||
"""
|
||||
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
raw_app_id = kwargs.get("app_id") or kwargs.get("resource_id")
|
||||
if raw_app_id is not None:
|
||||
app_model = _load_app_model_from_scoped_session(str(raw_app_id))
|
||||
binding = (
|
||||
app_model.agent_app_binding_with_session(session=db.session(), include_archived=True)
|
||||
if app_model is not None
|
||||
else None
|
||||
)
|
||||
if binding is not None:
|
||||
if binding.scope == AgentScope.WORKFLOW_ONLY:
|
||||
raise AppNotFoundError()
|
||||
if dify_config.RBAC_ENABLED:
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
enforce_rbac_access(
|
||||
tenant_id=current_tenant_id,
|
||||
account_id=current_user.id,
|
||||
resource_type=RBACResourceScope.WORKSPACE,
|
||||
scene=RBACPermission.AGENT_MANAGE,
|
||||
resource_required=False,
|
||||
)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def _get_injected_session(args: tuple[object, ...]) -> Session | None:
|
||||
"""Return the request session inserted by `with_session`, if this handler has been migrated."""
|
||||
if len(args) < 2:
|
||||
|
||||
@@ -61,7 +61,6 @@ class RBACPermission(StrEnum):
|
||||
WORKSPACE_ROLE_MANAGE = "workspace_role_manage"
|
||||
API_EXTENSION_MANAGE = "api_extension_manage"
|
||||
CUSTOMIZATION_MANAGE = "customization_manage"
|
||||
AGENT_MANAGE = "agent_manage"
|
||||
|
||||
SNIPPETS_CREATE_AND_MODIFY = "snippets_create_and_modify"
|
||||
SNIPPETS_MANAGE = "snippets_management"
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
import pytz # type: ignore[import-untyped]
|
||||
from celery import Celery, Task
|
||||
from celery.schedules import crontab
|
||||
from celery.signals import beat_init
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from configs import dify_config
|
||||
@@ -36,6 +37,19 @@ class CeleryBeatScheduleEntry(TypedDict):
|
||||
schedule: crontab | timedelta
|
||||
|
||||
|
||||
def _enqueue_initial_community_telemetry_heartbeat(sender: Any, **_: Any) -> None:
|
||||
task_name = "community_telemetry.send_heartbeat"
|
||||
if "community_telemetry_heartbeat" not in sender.app.conf.beat_schedule:
|
||||
return
|
||||
|
||||
task = sender.app.tasks.get(task_name)
|
||||
if task is not None:
|
||||
task.apply_async()
|
||||
|
||||
|
||||
beat_init.connect(_enqueue_initial_community_telemetry_heartbeat, weak=False)
|
||||
|
||||
|
||||
def get_celery_ssl_options() -> CelerySSLOptionsDict | None:
|
||||
"""Get SSL configuration for Celery broker/backend connections."""
|
||||
# Only apply SSL if we're using Redis as broker/backend
|
||||
@@ -260,6 +274,19 @@ def init_app(app: DifyApp) -> Celery:
|
||||
"schedule": timedelta(minutes=dify_config.API_TOKEN_LAST_USED_UPDATE_INTERVAL),
|
||||
}
|
||||
|
||||
if (
|
||||
dify_config.EDITION == "SELF_HOSTED"
|
||||
and not dify_config.ENTERPRISE_ENABLED
|
||||
and not dify_config.DISABLE_TELEMETRY
|
||||
and not dify_config.DO_NOT_TRACK
|
||||
and not dify_config.CI
|
||||
):
|
||||
imports.append("tasks.community_telemetry_task")
|
||||
beat_schedule["community_telemetry_heartbeat"] = {
|
||||
"task": "community_telemetry.send_heartbeat",
|
||||
"schedule": timedelta(minutes=dify_config.TELEMETRY_HEARTBEAT_INTERVAL_MINUTES),
|
||||
}
|
||||
|
||||
if dify_config.ENTERPRISE_ENABLED and dify_config.ENTERPRISE_TELEMETRY_ENABLED:
|
||||
imports.append("tasks.enterprise_telemetry_task")
|
||||
celery_app.conf.update(beat_schedule=beat_schedule, imports=imports)
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
"""add telemetry fields to dify_setups
|
||||
|
||||
Revision ID: 6f5a9c2d8e1b
|
||||
Revises: d2825e7b9c10
|
||||
Create Date: 2026-07-23 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "6f5a9c2d8e1b"
|
||||
down_revision = "d2825e7b9c10"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("dify_setups", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("instance_id", sa.String(length=255), nullable=True))
|
||||
batch_op.add_column(sa.Column("install_reported_at", sa.DateTime(), nullable=True))
|
||||
batch_op.add_column(sa.Column("last_heartbeat_at", sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("dify_setups", schema=None) as batch_op:
|
||||
batch_op.drop_column("last_heartbeat_at")
|
||||
batch_op.drop_column("install_reported_at")
|
||||
batch_op.drop_column("instance_id")
|
||||
+16
-31
@@ -56,7 +56,6 @@ from .provider_ids import GenericProviderID
|
||||
from .types import EnumText, LongText, StringUUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .agent import Agent
|
||||
from .workflow import Workflow
|
||||
|
||||
|
||||
@@ -362,6 +361,9 @@ class DifySetup(TypeBase):
|
||||
__table_args__ = (sa.PrimaryKeyConstraint("version", name="dify_setup_pkey"),)
|
||||
|
||||
version: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
instance_id: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
|
||||
install_reported_at: Mapped[datetime | None] = mapped_column(sa.DateTime, nullable=True, default=None)
|
||||
last_heartbeat_at: Mapped[datetime | None] = mapped_column(sa.DateTime, nullable=True, default=None)
|
||||
setup_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
@@ -502,42 +504,25 @@ class App(Base):
|
||||
Resolved via ``Agent.app_id`` so the console can open the Composer in
|
||||
roster-detail mode from the app id. ``None`` for non-agent apps.
|
||||
"""
|
||||
agent = self.agent_app_binding_with_session(session=session)
|
||||
return agent.id if agent else None
|
||||
|
||||
def agent_app_binding_with_session(self, *, session: Session, include_archived: bool = False) -> Agent | None:
|
||||
"""For an Agent App (mode=agent), the Agent bound to it.
|
||||
|
||||
A roster Agent is bound through ``Agent.app_id``; a workflow-only Agent
|
||||
is bound to its hidden runtime backing App through
|
||||
``Agent.backing_app_id``. Callers branch on ``Agent.scope`` to tell the
|
||||
public roster Agent App apart from the hidden backing App. Archived
|
||||
Agents are excluded unless ``include_archived`` is set (authorization
|
||||
gates must keep covering an Agent App after its Agent is archived).
|
||||
``None`` for non-agent apps and unbound agent apps.
|
||||
"""
|
||||
if self.mode != AppMode.AGENT:
|
||||
return None
|
||||
from .agent import APP_BACKED_AGENT_SOURCES, Agent, AgentScope, AgentStatus
|
||||
|
||||
conditions = [
|
||||
Agent.tenant_id == self.tenant_id,
|
||||
sa.or_(
|
||||
sa.and_(
|
||||
Agent.app_id == self.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
|
||||
),
|
||||
sa.and_(
|
||||
agent = session.scalar(
|
||||
select(Agent).where(
|
||||
Agent.tenant_id == self.tenant_id,
|
||||
sa.or_(
|
||||
sa.and_(
|
||||
Agent.app_id == self.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
|
||||
),
|
||||
Agent.backing_app_id == self.id,
|
||||
Agent.scope == AgentScope.WORKFLOW_ONLY,
|
||||
),
|
||||
),
|
||||
]
|
||||
if not include_archived:
|
||||
conditions.append(Agent.status == AgentStatus.ACTIVE)
|
||||
|
||||
return session.scalar(select(Agent).where(*conditions).limit(1))
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
return agent.id if agent else None
|
||||
|
||||
@property
|
||||
def api_base_url(self) -> str:
|
||||
|
||||
@@ -17318,12 +17318,6 @@ Default model entity.
|
||||
| tool_name | string | | Yes |
|
||||
| type | string | | Yes |
|
||||
|
||||
#### DeploymentEdition
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| DeploymentEdition | string | | |
|
||||
|
||||
#### DismissNotificationPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -22032,7 +22026,6 @@ Model class for provider system configuration response.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| branding | [BrandingModel](#brandingmodel) | | Yes |
|
||||
| deployment_edition | [DeploymentEdition](#deploymentedition) | | Yes |
|
||||
| enable_app_deploy | boolean | | Yes |
|
||||
| enable_change_email | boolean, <br>**Default:** true | | Yes |
|
||||
| enable_collaboration_mode | boolean, <br>**Default:** true | | Yes |
|
||||
|
||||
@@ -1058,12 +1058,6 @@ Button styles for user actions.
|
||||
| auto_generate | boolean | Automatically generate the conversation name. When `true`, the `name` field is ignored. | No |
|
||||
| name | string | Conversation name. Required when `auto_generate` is `false`. | No |
|
||||
|
||||
#### DeploymentEdition
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| DeploymentEdition | string | | |
|
||||
|
||||
#### EmailCodeLoginSendPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -1573,7 +1567,6 @@ Default configuration for form inputs.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| branding | [BrandingModel](#brandingmodel) | | Yes |
|
||||
| deployment_edition | [DeploymentEdition](#deploymentedition) | | Yes |
|
||||
| enable_app_deploy | boolean | | Yes |
|
||||
| enable_change_email | boolean, <br>**Default:** true | | Yes |
|
||||
| enable_collaboration_mode | boolean, <br>**Default:** true | | Yes |
|
||||
|
||||
@@ -75,6 +75,7 @@ from services.errors.account import (
|
||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
|
||||
from services.feature_service import FeatureService
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
from services.telemetry_service import CommunityTelemetryService
|
||||
from tasks.delete_account_task import delete_account_task
|
||||
from tasks.mail_account_deletion_task import send_account_deletion_verification_code
|
||||
from tasks.mail_change_mail_task import (
|
||||
@@ -1961,7 +1962,7 @@ class RegisterService:
|
||||
|
||||
TenantService.create_owner_tenant_if_not_exist(account=account, is_setup=True, session=session)
|
||||
|
||||
dify_setup = DifySetup(version=dify_config.project.version)
|
||||
dify_setup = DifySetup(version=dify_config.project.version, instance_id=str(uuid.uuid4()))
|
||||
session.add(dify_setup)
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
@@ -1974,6 +1975,11 @@ class RegisterService:
|
||||
logger.exception("Setup account failed, email: %s, name: %s", email, name)
|
||||
raise ValueError(f"Setup failed: {e}")
|
||||
|
||||
try:
|
||||
CommunityTelemetryService.report_install(session=session)
|
||||
except Exception:
|
||||
logger.debug("Failed to report install telemetry", exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def register(
|
||||
cls,
|
||||
|
||||
@@ -911,14 +911,16 @@ class AgentRosterService:
|
||||
raise AgentNotFoundError()
|
||||
return app
|
||||
|
||||
def _get_runtime_resolvable_agent(self, *, tenant_id: str, agent_id: str) -> Agent | None:
|
||||
"""Load an Agent that is eligible to resolve to a runtime backing App.
|
||||
def get_agent_runtime_app_model(self, *, tenant_id: str, agent_id: str) -> App:
|
||||
"""Resolve the App that backs an Agent runtime surface.
|
||||
|
||||
Shared by the runtime resolver and the read-only authorization resolver
|
||||
so both agree on what counts as a resolvable Agent.
|
||||
Roster Agents use their public Agent App. Workflow-only Agents use a
|
||||
hidden Agent App stored in ``backing_app_id`` so console chat/logs can
|
||||
reuse the app runtime without exposing the resource in workspace app
|
||||
lists.
|
||||
"""
|
||||
|
||||
return self._session.scalar(
|
||||
agent = self._session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
@@ -936,34 +938,6 @@ class AgentRosterService:
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
def peek_authz_app_id(self, *, tenant_id: str, agent_id: str) -> str | None:
|
||||
"""Resolve the App id whose access policy governs an Agent.
|
||||
|
||||
Roster Agents are governed by their own Agent App, while workflow-only
|
||||
Agents are governed by their parent workflow App: the hidden runtime
|
||||
backing App never receives a resource access policy, so it must not be
|
||||
used for authorization. Stays read-only — unlike
|
||||
:meth:`get_agent_runtime_app_model`, this never materializes the hidden
|
||||
backing App. Returns ``None`` when the Agent does not resolve, leaving
|
||||
the caller to decide how to treat it.
|
||||
"""
|
||||
|
||||
agent = self._get_runtime_resolvable_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
if agent is None:
|
||||
return None
|
||||
return agent.app_id
|
||||
|
||||
def get_agent_runtime_app_model(self, *, tenant_id: str, agent_id: str) -> App:
|
||||
"""Resolve the App that backs an Agent runtime surface.
|
||||
|
||||
Roster Agents use their public Agent App. Workflow-only Agents use a
|
||||
hidden Agent App stored in ``backing_app_id`` so console chat/logs can
|
||||
reuse the app runtime without exposing the resource in workspace app
|
||||
lists.
|
||||
"""
|
||||
|
||||
agent = self._get_runtime_resolvable_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
if agent is None:
|
||||
raise AgentNotFoundError()
|
||||
should_commit_backing_app = agent.scope == AgentScope.WORKFLOW_ONLY and not agent.backing_app_id
|
||||
|
||||
@@ -19,7 +19,6 @@ from configs import dify_config
|
||||
from constants.dsl_version import CURRENT_APP_DSL_VERSION
|
||||
from core.file import remote_fetcher
|
||||
from core.plugin.entities.plugin import PluginDependency
|
||||
from core.rbac import RBACPermission
|
||||
from core.trigger.constants import (
|
||||
TRIGGER_PLUGIN_NODE_TYPE,
|
||||
TRIGGER_SCHEDULE_NODE_TYPE,
|
||||
@@ -44,9 +43,7 @@ from services.agent.dsl_service import AgentDslService, AgentPackage
|
||||
from services.agent.workflow_publish_service import WorkflowAgentPublishService
|
||||
from services.dsl_content import DSL_MAX_SIZE, dsl_content_size
|
||||
from services.dsl_version import check_version_compatibility
|
||||
from services.enterprise.rbac_service import RBACService
|
||||
from services.entities.dsl_entities import CheckDependenciesResult, DslImportWarning, ImportMode, ImportStatus
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.errors.app import WorkflowNotFoundError
|
||||
from services.plugin.dependencies_analysis import DependenciesAnalysisService
|
||||
from services.workflow_draft_variable_service import WorkflowDraftVariableService
|
||||
@@ -304,9 +301,6 @@ class AppDslService:
|
||||
error=f"Invalid YAML format: {str(e)}",
|
||||
)
|
||||
|
||||
except NoPermissionError:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to import app")
|
||||
return Import(
|
||||
@@ -370,9 +364,6 @@ class AppDslService:
|
||||
warnings=self._warnings,
|
||||
)
|
||||
|
||||
except NoPermissionError:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error confirming import")
|
||||
return Import(
|
||||
@@ -404,21 +395,6 @@ class AppDslService:
|
||||
leaked_dependencies=leaked_dependencies,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_agent_manage_permission(account: Account) -> None:
|
||||
"""Importing an Agent DSL creates a roster Agent, which requires ``agent.manage``."""
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
return
|
||||
if account.current_tenant_id is None:
|
||||
raise ValueError("Current tenant is not set")
|
||||
allowed = RBACService.CheckAccess.check(
|
||||
account.current_tenant_id,
|
||||
account.id,
|
||||
scene=RBACPermission.AGENT_MANAGE,
|
||||
)
|
||||
if not allowed:
|
||||
raise NoPermissionError("Agent management permission is required to import an Agent App")
|
||||
|
||||
def _create_or_update_app(
|
||||
self,
|
||||
*,
|
||||
@@ -439,8 +415,6 @@ class AppDslService:
|
||||
if not app_mode:
|
||||
raise ValueError("loss app mode")
|
||||
app_mode = AppMode(app_mode)
|
||||
if app_mode == AppMode.AGENT:
|
||||
self._ensure_agent_manage_permission(account)
|
||||
|
||||
# Set icon type
|
||||
icon_type_value = icon_type or app_data.get("icon_type")
|
||||
|
||||
@@ -330,7 +330,6 @@ _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [
|
||||
"snippets.management",
|
||||
"tool.manage",
|
||||
"mcp.manage",
|
||||
"agent.manage",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
|
||||
@@ -358,7 +357,6 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
|
||||
"snippets.management",
|
||||
"tool.manage",
|
||||
"mcp.manage",
|
||||
"agent.manage",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
|
||||
@@ -373,7 +371,6 @@ _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
|
||||
"dataset.external.connect",
|
||||
"snippets.create_and_modify",
|
||||
"tool.manage",
|
||||
"agent.manage",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
|
||||
|
||||
@@ -75,12 +75,6 @@ class LicenseStatus(StrEnum):
|
||||
LOST = "lost"
|
||||
|
||||
|
||||
class DeploymentEdition(StrEnum):
|
||||
COMMUNITY = "COMMUNITY"
|
||||
ENTERPRISE = "ENTERPRISE"
|
||||
CLOUD = "CLOUD"
|
||||
|
||||
|
||||
class LicenseModel(FeatureResponseModel):
|
||||
status: LicenseStatus = LicenseStatus.NONE
|
||||
expired_at: str = ""
|
||||
@@ -168,7 +162,6 @@ class PluginManagerModel(FeatureResponseModel):
|
||||
|
||||
|
||||
class SystemFeatureModel(FeatureResponseModel):
|
||||
deployment_edition: DeploymentEdition
|
||||
enable_app_deploy: bool = False
|
||||
sso_enforced_for_signin: bool = False
|
||||
sso_enforced_for_signin_protocol: str = ""
|
||||
@@ -259,7 +252,7 @@ class FeatureService:
|
||||
|
||||
@classmethod
|
||||
def get_system_features(cls, is_authenticated: bool = False) -> SystemFeatureModel:
|
||||
system_features = SystemFeatureModel(deployment_edition=cls._resolve_deployment_edition())
|
||||
system_features = SystemFeatureModel()
|
||||
system_features.rbac_enabled = dify_config.RBAC_ENABLED
|
||||
|
||||
cls._fulfill_system_params_from_env(system_features)
|
||||
@@ -279,14 +272,6 @@ class FeatureService:
|
||||
|
||||
return system_features
|
||||
|
||||
@classmethod
|
||||
def _resolve_deployment_edition(cls) -> DeploymentEdition:
|
||||
if dify_config.EDITION == "CLOUD":
|
||||
return DeploymentEdition.CLOUD
|
||||
if dify_config.ENTERPRISE_ENABLED:
|
||||
return DeploymentEdition.ENTERPRISE
|
||||
return DeploymentEdition.COMMUNITY
|
||||
|
||||
@classmethod
|
||||
def get_app_dsl_version(cls) -> str:
|
||||
return CURRENT_APP_DSL_VERSION
|
||||
@@ -300,7 +285,6 @@ class FeatureService:
|
||||
system_features.is_allow_register = dify_config.ALLOW_REGISTER
|
||||
system_features.is_allow_create_workspace = dify_config.ALLOW_CREATE_WORKSPACE
|
||||
system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != ""
|
||||
system_features.enable_change_email = dify_config.ENABLE_CHANGE_EMAIL
|
||||
system_features.enable_trial_app = dify_config.ENABLE_TRIAL_APP
|
||||
system_features.enable_explore_banner = dify_config.ENABLE_EXPLORE_BANNER
|
||||
system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import logging
|
||||
import platform
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.model import DifySetup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TelemetryEvent = Literal["install", "heartbeat"]
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
class CommunityTelemetryService:
|
||||
@classmethod
|
||||
def report_install(cls, *, session: Session) -> bool:
|
||||
setup = cls._get_setup(session)
|
||||
if setup is None:
|
||||
return False
|
||||
|
||||
if setup.instance_id is None:
|
||||
setup.instance_id = str(uuid.uuid4())
|
||||
session.add(setup)
|
||||
session.commit()
|
||||
|
||||
payload = cls._build_payload(setup, "install")
|
||||
if not cls._send_event(payload):
|
||||
return False
|
||||
|
||||
setup.install_reported_at = naive_utc_now()
|
||||
session.add(setup)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def report_heartbeat(cls, *, session: Session, now: datetime | None = None) -> bool:
|
||||
setup = cls._get_setup(session)
|
||||
if setup is None:
|
||||
return False
|
||||
|
||||
if setup.instance_id is None:
|
||||
setup.instance_id = str(uuid.uuid4())
|
||||
session.add(setup)
|
||||
session.commit()
|
||||
|
||||
now = now or naive_utc_now()
|
||||
if not cls._is_heartbeat_due(setup, now):
|
||||
return False
|
||||
|
||||
if setup.install_reported_at is None:
|
||||
cls.report_install(session=session)
|
||||
|
||||
payload = cls._build_payload(setup, "heartbeat")
|
||||
if not cls._send_event(payload):
|
||||
return False
|
||||
|
||||
setup.last_heartbeat_at = now
|
||||
session.add(setup)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def _get_setup(cls, session: Session) -> DifySetup | None:
|
||||
return session.scalar(select(DifySetup).order_by(DifySetup.setup_at.asc()).limit(1))
|
||||
|
||||
@classmethod
|
||||
def _is_enabled(cls) -> bool:
|
||||
return (
|
||||
dify_config.EDITION == "SELF_HOSTED"
|
||||
and not dify_config.ENTERPRISE_ENABLED
|
||||
and not dify_config.DISABLE_TELEMETRY
|
||||
and not dify_config.DO_NOT_TRACK
|
||||
and not dify_config.CI
|
||||
and bool(dify_config.TELEMETRY_ENDPOINT)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _build_payload(cls, setup: DifySetup, event: TelemetryEvent) -> dict[str, str | int]:
|
||||
payload: dict[str, str | int] = {
|
||||
"event": event,
|
||||
"instance_id": setup.instance_id or "",
|
||||
"version": setup.version if event == "install" else dify_config.project.version,
|
||||
"edition": dify_config.EDITION,
|
||||
"deployment_type": "unknown",
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"os": cls._normalize_os(platform.system()),
|
||||
"arch": cls._normalize_arch(platform.machine()),
|
||||
"sent_at": cls._format_datetime(naive_utc_now()),
|
||||
}
|
||||
|
||||
if event == "install":
|
||||
payload["installed_at"] = cls._format_datetime(setup.setup_at)
|
||||
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def _send_event(cls, payload: dict[str, str | int]) -> bool:
|
||||
if not cls._is_enabled():
|
||||
return False
|
||||
|
||||
endpoints = [dify_config.TELEMETRY_ENDPOINT]
|
||||
if dify_config.TELEMETRY_FALLBACK_ENDPOINT not in endpoints:
|
||||
endpoints.append(dify_config.TELEMETRY_FALLBACK_ENDPOINT)
|
||||
|
||||
for endpoint in endpoints:
|
||||
if not endpoint:
|
||||
continue
|
||||
|
||||
try:
|
||||
response = httpx.post(
|
||||
endpoint,
|
||||
json=payload,
|
||||
timeout=dify_config.TELEMETRY_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except httpx.RequestError:
|
||||
logger.debug("Failed to send community telemetry event to %s", endpoint, exc_info=True)
|
||||
except httpx.HTTPStatusError:
|
||||
logger.debug("Community telemetry endpoint returned an error: %s", endpoint, exc_info=True)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _is_heartbeat_due(cls, setup: DifySetup, now: datetime) -> bool:
|
||||
if setup.instance_id is None:
|
||||
return False
|
||||
|
||||
if setup.last_heartbeat_at is not None and setup.last_heartbeat_at.date() >= now.date():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _format_datetime(value: datetime) -> str:
|
||||
return value.replace(microsecond=0).isoformat() + "Z"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_os(value: str) -> str:
|
||||
os_name = value.lower()
|
||||
if os_name in {"linux", "darwin", "windows"}:
|
||||
return os_name
|
||||
return "unknown"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_arch(value: str) -> str:
|
||||
arch = value.lower()
|
||||
if arch in {"x86_64", "amd64"}:
|
||||
return "amd64"
|
||||
if arch in {"aarch64", "arm64"}:
|
||||
return "arm64"
|
||||
if arch.startswith("arm"):
|
||||
return "arm"
|
||||
if arch in {"i386", "i686", "x86"}:
|
||||
return "386"
|
||||
return "unknown"
|
||||
@@ -0,0 +1,19 @@
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from extensions.ext_database import db
|
||||
from services.telemetry_service import CommunityTelemetryService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(name="community_telemetry.send_heartbeat", queue="schedule_executor")
|
||||
def send_community_telemetry_heartbeat() -> None:
|
||||
session_factory = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
with session_factory() as session:
|
||||
try:
|
||||
CommunityTelemetryService.report_heartbeat(session=session)
|
||||
except Exception:
|
||||
logger.debug("Failed to process community telemetry heartbeat", exc_info=True)
|
||||
@@ -414,7 +414,6 @@ class TestFeatureService:
|
||||
mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True
|
||||
mock_config.ENABLE_SOCIAL_OAUTH_LOGIN = False
|
||||
mock_config.ENABLE_COLLABORATION_MODE = False
|
||||
mock_config.ENABLE_CHANGE_EMAIL = True
|
||||
mock_config.ALLOW_REGISTER = True
|
||||
mock_config.ALLOW_CREATE_WORKSPACE = True
|
||||
mock_config.MAIL_TYPE = "smtp"
|
||||
@@ -617,7 +616,6 @@ class TestFeatureService:
|
||||
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
|
||||
mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True
|
||||
mock_config.ENABLE_SOCIAL_OAUTH_LOGIN = True
|
||||
mock_config.ENABLE_CHANGE_EMAIL = True
|
||||
mock_config.ALLOW_REGISTER = False
|
||||
mock_config.ALLOW_CREATE_WORKSPACE = False
|
||||
mock_config.MAIL_TYPE = None
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from controllers.console.app.error import AppNotFoundError
|
||||
from controllers.console.app.wraps import agent_manage_required_for_agent_app
|
||||
from core.rbac import RBACPermission, RBACResourceScope
|
||||
from models.agent import AgentScope
|
||||
|
||||
TENANT_ID = "tenant-1"
|
||||
ACCOUNT = SimpleNamespace(id="account-1")
|
||||
|
||||
|
||||
def _guarded_view():
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
@agent_manage_required_for_agent_app
|
||||
def view(*args, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return "ok"
|
||||
|
||||
return view, calls
|
||||
|
||||
|
||||
def _app_with_binding(binding):
|
||||
app_model = MagicMock()
|
||||
app_model.agent_app_binding_with_session.return_value = binding
|
||||
return app_model
|
||||
|
||||
|
||||
def _patch_guard(app_model, rbac_enabled: bool):
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.scalar.return_value = app_model
|
||||
return (
|
||||
patch("controllers.console.app.wraps.db", mock_db),
|
||||
patch("controllers.console.app.wraps.current_account_with_tenant", return_value=(ACCOUNT, TENANT_ID)),
|
||||
patch("controllers.console.app.wraps.dify_config.RBAC_ENABLED", rbac_enabled),
|
||||
)
|
||||
|
||||
|
||||
class TestAgentManageRequiredForAgentApp:
|
||||
def test_non_agent_app_passes_through_without_workspace_check(self):
|
||||
view, calls = _guarded_view()
|
||||
patches = _patch_guard(_app_with_binding(None), rbac_enabled=True)
|
||||
|
||||
with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access") as gate:
|
||||
assert view(app_id="app-1") == "ok"
|
||||
|
||||
gate.assert_not_called()
|
||||
assert calls == [{"app_id": "app-1"}]
|
||||
|
||||
def test_roster_agent_app_requires_agent_manage_when_rbac_enabled(self):
|
||||
view, _ = _guarded_view()
|
||||
binding = SimpleNamespace(scope=AgentScope.ROSTER)
|
||||
patches = _patch_guard(_app_with_binding(binding), rbac_enabled=True)
|
||||
|
||||
with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access") as gate:
|
||||
assert view(app_id="app-1") == "ok"
|
||||
|
||||
gate.assert_called_once_with(
|
||||
tenant_id=TENANT_ID,
|
||||
account_id=ACCOUNT.id,
|
||||
resource_type=RBACResourceScope.WORKSPACE,
|
||||
scene=RBACPermission.AGENT_MANAGE,
|
||||
resource_required=False,
|
||||
)
|
||||
|
||||
def test_roster_agent_app_denied_without_agent_manage(self):
|
||||
view, calls = _guarded_view()
|
||||
binding = SimpleNamespace(scope=AgentScope.ROSTER)
|
||||
patches = _patch_guard(_app_with_binding(binding), rbac_enabled=True)
|
||||
|
||||
with (
|
||||
patches[0],
|
||||
patches[1],
|
||||
patches[2],
|
||||
patch("controllers.console.app.wraps.enforce_rbac_access", side_effect=Forbidden()),
|
||||
):
|
||||
with pytest.raises(Forbidden):
|
||||
view(app_id="app-1")
|
||||
|
||||
assert calls == []
|
||||
|
||||
def test_roster_agent_app_skips_workspace_check_when_rbac_disabled(self):
|
||||
view, _ = _guarded_view()
|
||||
binding = SimpleNamespace(scope=AgentScope.ROSTER)
|
||||
patches = _patch_guard(_app_with_binding(binding), rbac_enabled=False)
|
||||
|
||||
with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access") as gate:
|
||||
assert view(app_id="app-1") == "ok"
|
||||
|
||||
gate.assert_not_called()
|
||||
|
||||
def test_hidden_backing_app_is_rejected_even_without_rbac(self):
|
||||
"""A workflow-only backing App is not part of the general app management plane."""
|
||||
view, calls = _guarded_view()
|
||||
binding = SimpleNamespace(scope=AgentScope.WORKFLOW_ONLY)
|
||||
patches = _patch_guard(_app_with_binding(binding), rbac_enabled=False)
|
||||
|
||||
with patches[0], patches[1], patches[2]:
|
||||
with pytest.raises(AppNotFoundError):
|
||||
view(app_id="app-1")
|
||||
|
||||
assert calls == []
|
||||
|
||||
def test_hidden_backing_app_is_rejected_before_workspace_check(self):
|
||||
view, calls = _guarded_view()
|
||||
binding = SimpleNamespace(scope=AgentScope.WORKFLOW_ONLY)
|
||||
patches = _patch_guard(_app_with_binding(binding), rbac_enabled=True)
|
||||
|
||||
with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access") as gate:
|
||||
with pytest.raises(AppNotFoundError):
|
||||
view(app_id="app-1")
|
||||
|
||||
gate.assert_not_called()
|
||||
assert calls == []
|
||||
|
||||
def test_binding_lookup_covers_archived_agents(self):
|
||||
"""An Agent App stays gated after its roster Agent is archived."""
|
||||
view, _ = _guarded_view()
|
||||
app_model = _app_with_binding(SimpleNamespace(scope=AgentScope.ROSTER))
|
||||
patches = _patch_guard(app_model, rbac_enabled=True)
|
||||
|
||||
with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access"):
|
||||
view(app_id="app-1")
|
||||
|
||||
_, call_kwargs = app_model.agent_app_binding_with_session.call_args
|
||||
assert call_kwargs["include_archived"] is True
|
||||
|
||||
def test_resource_id_path_alias_is_resolved(self):
|
||||
view, _ = _guarded_view()
|
||||
binding = SimpleNamespace(scope=AgentScope.ROSTER)
|
||||
patches = _patch_guard(_app_with_binding(binding), rbac_enabled=True)
|
||||
|
||||
with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access") as gate:
|
||||
assert view(resource_id="app-1") == "ok"
|
||||
|
||||
gate.assert_called_once()
|
||||
|
||||
def test_unknown_app_passes_through_for_downstream_handling(self):
|
||||
view, calls = _guarded_view()
|
||||
patches = _patch_guard(None, rbac_enabled=True)
|
||||
|
||||
with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access") as gate:
|
||||
assert view(app_id="app-1") == "ok"
|
||||
|
||||
gate.assert_not_called()
|
||||
assert calls == [{"app_id": "app-1"}]
|
||||
@@ -19,7 +19,7 @@ from models.engine import db
|
||||
from models.model import App, AppMode
|
||||
from services.app_dsl_service import ImportStatus
|
||||
from services.entities.dsl_entities import CheckDependenciesResult
|
||||
from services.feature_service import DeploymentEdition, SystemFeatureModel, WebAppAuthModel
|
||||
from services.feature_service import SystemFeatureModel, WebAppAuthModel
|
||||
|
||||
|
||||
def _unwrap(func):
|
||||
@@ -47,10 +47,7 @@ class _Result:
|
||||
|
||||
|
||||
def _install_features(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None:
|
||||
features = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
webapp_auth=WebAppAuthModel(enabled=enabled),
|
||||
)
|
||||
features = SystemFeatureModel(webapp_auth=WebAppAuthModel(enabled=enabled))
|
||||
monkeypatch.setattr(app_import_module.FeatureService, "get_system_features", lambda: features)
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from controllers.console.auth.email_register import (
|
||||
EmailRegisterResetApi,
|
||||
EmailRegisterSendEmailApi,
|
||||
)
|
||||
from services.feature_service import DeploymentEdition, SystemFeatureModel
|
||||
from services.feature_service import SystemFeatureModel
|
||||
|
||||
|
||||
class TestEmailRegisterSendEmailApi:
|
||||
@@ -34,11 +34,7 @@ class TestEmailRegisterSendEmailApi:
|
||||
mock_account = MagicMock()
|
||||
mock_get_account.return_value = mock_account
|
||||
|
||||
feature_flags = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_email_password_login=True,
|
||||
is_allow_register=True,
|
||||
)
|
||||
feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
|
||||
with (
|
||||
patch("controllers.console.auth.email_register.dify_config.BILLING_ENABLED", True),
|
||||
patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"),
|
||||
@@ -79,11 +75,7 @@ class TestEmailRegisterCheckApi:
|
||||
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,
|
||||
)
|
||||
feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
|
||||
with (
|
||||
patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"),
|
||||
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
|
||||
@@ -131,11 +123,7 @@ class TestEmailRegisterResetApi:
|
||||
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,
|
||||
)
|
||||
feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
|
||||
with (
|
||||
patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"),
|
||||
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
|
||||
@@ -183,11 +171,7 @@ class TestEmailRegisterResetApi:
|
||||
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,
|
||||
)
|
||||
feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
|
||||
with (
|
||||
patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"),
|
||||
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
|
||||
@@ -240,11 +224,7 @@ class TestEmailRegisterResetApi:
|
||||
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,
|
||||
)
|
||||
feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
|
||||
with (
|
||||
patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"),
|
||||
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
|
||||
|
||||
@@ -15,7 +15,7 @@ from controllers.console.auth.forgot_password import (
|
||||
)
|
||||
from models.account import Account
|
||||
from models.engine import db
|
||||
from services.feature_service import DeploymentEdition, SystemFeatureModel
|
||||
from services.feature_service import SystemFeatureModel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -46,15 +46,8 @@ class TestForgotPasswordSendEmailApi:
|
||||
mock_get_account.return_value = mock_account
|
||||
mock_send_email.return_value = "token-123"
|
||||
|
||||
wraps_features = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_email_password_login=True,
|
||||
is_allow_register=True,
|
||||
)
|
||||
controller_features = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
is_allow_register=True,
|
||||
)
|
||||
wraps_features = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
|
||||
controller_features = SystemFeatureModel(is_allow_register=True)
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.auth.forgot_password.FeatureService.get_system_features",
|
||||
@@ -102,10 +95,7 @@ class TestForgotPasswordCheckApi:
|
||||
mock_get_data.return_value = {"email": "Admin@Example.com", "code": "4321"}
|
||||
mock_generate_token.return_value = (None, "new-token")
|
||||
|
||||
wraps_features = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_email_password_login=True,
|
||||
)
|
||||
wraps_features = SystemFeatureModel(enable_email_password_login=True)
|
||||
with (
|
||||
patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"),
|
||||
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features),
|
||||
@@ -148,10 +138,7 @@ class TestForgotPasswordResetApi:
|
||||
db.session.commit()
|
||||
mock_get_account.return_value = account
|
||||
|
||||
wraps_features = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_email_password_login=True,
|
||||
)
|
||||
wraps_features = SystemFeatureModel(enable_email_password_login=True)
|
||||
with (
|
||||
patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"),
|
||||
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features),
|
||||
|
||||
@@ -24,7 +24,7 @@ from controllers.console.auth.forgot_password import (
|
||||
)
|
||||
from controllers.console.error import AccountNotFound, EmailSendIpLimitError
|
||||
from models.account import Account, Tenant, TenantAccountJoin
|
||||
from services.feature_service import DeploymentEdition, SystemFeatureModel
|
||||
from services.feature_service import SystemFeatureModel
|
||||
|
||||
SQLITE_MODELS = (Account, Tenant, TenantAccountJoin)
|
||||
|
||||
@@ -48,10 +48,7 @@ def enable_password_login_wrappers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("controllers.console.wraps.dify_config.EDITION", "CLOUD")
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.wraps.FeatureService.get_system_features",
|
||||
lambda: SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_email_password_login=True,
|
||||
),
|
||||
lambda: SystemFeatureModel(enable_email_password_login=True),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from inspect import unwrap
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from models import Account
|
||||
from services.feature_service import DeploymentEdition, FeatureModel, LimitationModel, SystemFeatureModel
|
||||
from services.feature_service import FeatureModel, LimitationModel, SystemFeatureModel
|
||||
|
||||
|
||||
def make_account() -> Account:
|
||||
@@ -94,11 +94,7 @@ class TestSystemFeatureApi:
|
||||
"controllers.console.feature.current_account_with_tenant_optional",
|
||||
return_value=(account, "tenant-123"),
|
||||
)
|
||||
system_features = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
is_allow_register=True,
|
||||
enable_learn_app=True,
|
||||
)
|
||||
system_features = SystemFeatureModel(is_allow_register=True, enable_learn_app=True)
|
||||
get_system_features = mocker.patch(
|
||||
"controllers.console.feature.FeatureService.get_system_features",
|
||||
return_value=system_features,
|
||||
@@ -123,10 +119,7 @@ class TestSystemFeatureApi:
|
||||
"controllers.console.feature.current_account_with_tenant_optional",
|
||||
return_value=(None, None),
|
||||
)
|
||||
system_features = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
is_allow_register=False,
|
||||
)
|
||||
system_features = SystemFeatureModel(is_allow_register=False)
|
||||
get_system_features = mocker.patch(
|
||||
"controllers.console.feature.FeatureService.get_system_features",
|
||||
return_value=system_features,
|
||||
|
||||
@@ -201,7 +201,7 @@ class TestRbacPermissionRequired:
|
||||
):
|
||||
assert protected_view(app_id="app-123") == "ok"
|
||||
|
||||
mock_extract.assert_called_once_with(RBACResourceScope.APP, "tenant-1", {"app_id": "app-123"})
|
||||
mock_extract.assert_called_once_with("app", {"app_id": "app-123"})
|
||||
mock_owned.assert_called_once_with("tenant-1", "account-1", "app", "app-123")
|
||||
mock_check.assert_called_once_with(
|
||||
"tenant-1",
|
||||
@@ -307,7 +307,7 @@ class TestRbacPermissionRequired:
|
||||
with app.test_request_context("/"):
|
||||
request.view_args = {"app_id": "view-app"}
|
||||
|
||||
assert _extract_resource_id("app", "tenant-1", {"app_id": "path-app"}) == "path-app"
|
||||
assert _extract_resource_id("app", {"app_id": "path-app"}) == "path-app"
|
||||
|
||||
def test_extract_resource_id_falls_back_to_request_view_args(self):
|
||||
app = Flask(__name__)
|
||||
@@ -315,59 +315,22 @@ class TestRbacPermissionRequired:
|
||||
with app.test_request_context("/"):
|
||||
request.view_args = {"app_id": "view-app"}
|
||||
|
||||
assert _extract_resource_id("app", "tenant-1") == "view-app"
|
||||
assert _extract_resource_id("app") == "view-app"
|
||||
|
||||
def test_extract_resource_id_supports_legacy_route_aliases(self):
|
||||
app = Flask(__name__)
|
||||
|
||||
with app.test_request_context("/apps/app-1/api-keys"):
|
||||
request.view_args = {"resource_id": "app-1"}
|
||||
assert _extract_resource_id(RBACResourceScope.APP, "tenant-1") == "app-1"
|
||||
assert _extract_resource_id(RBACResourceScope.APP) == "app-1"
|
||||
|
||||
with app.test_request_context("/agent/agent-1/features"):
|
||||
request.view_args = {"agent_id": "agent-1"}
|
||||
assert _extract_resource_id(RBACResourceScope.APP) == "agent-1"
|
||||
|
||||
with app.test_request_context("/datasets/dataset-1/api-keys"):
|
||||
request.view_args = {"resource_id": "dataset-1"}
|
||||
assert _extract_resource_id(RBACResourceScope.DATASET, "tenant-1") == "dataset-1"
|
||||
|
||||
def test_extract_resource_id_resolves_agent_to_its_authz_app(self):
|
||||
app = Flask(__name__)
|
||||
|
||||
with (
|
||||
app.test_request_context("/agent/agent-1/chat-messages"),
|
||||
patch("controllers.common.wraps.AgentRosterService") as mock_service,
|
||||
):
|
||||
request.view_args = {"agent_id": "agent-1"}
|
||||
mock_service.return_value.peek_authz_app_id.return_value = "parent-app-1"
|
||||
|
||||
assert _extract_resource_id(RBACResourceScope.APP, "tenant-1") == "parent-app-1"
|
||||
|
||||
def test_extract_resource_id_scopes_agent_resolution_to_the_calling_tenant(self):
|
||||
"""The tenant must reach the resolver, or an Agent id from any tenant resolves."""
|
||||
app = Flask(__name__)
|
||||
|
||||
with (
|
||||
app.test_request_context("/agent/agent-1/chat-messages"),
|
||||
patch("controllers.common.wraps.AgentRosterService") as mock_service,
|
||||
):
|
||||
request.view_args = {"agent_id": "agent-1"}
|
||||
mock_service.return_value.peek_authz_app_id.return_value = "parent-app-1"
|
||||
|
||||
_extract_resource_id(RBACResourceScope.APP, "tenant-9")
|
||||
|
||||
mock_service.return_value.peek_authz_app_id.assert_called_once_with(
|
||||
tenant_id="tenant-9", agent_id="agent-1"
|
||||
)
|
||||
|
||||
def test_extract_resource_id_keeps_agent_id_when_the_agent_does_not_resolve(self):
|
||||
app = Flask(__name__)
|
||||
|
||||
with (
|
||||
app.test_request_context("/agent/agent-1/chat-messages"),
|
||||
patch("controllers.common.wraps.AgentRosterService") as mock_service,
|
||||
):
|
||||
request.view_args = {"agent_id": "agent-1"}
|
||||
mock_service.return_value.peek_authz_app_id.return_value = None
|
||||
|
||||
assert _extract_resource_id(RBACResourceScope.APP, "tenant-1") == "agent-1"
|
||||
assert _extract_resource_id(RBACResourceScope.DATASET) == "dataset-1"
|
||||
|
||||
def test_legacy_admin_decorator_noops_when_rbac_enabled(self):
|
||||
@is_admin_or_owner_required
|
||||
|
||||
@@ -16,7 +16,7 @@ from controllers.web.forgot_password import (
|
||||
)
|
||||
from models.account import Account
|
||||
from models.engine import db
|
||||
from services.feature_service import DeploymentEdition, SystemFeatureModel
|
||||
from services.feature_service import SystemFeatureModel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -32,10 +32,7 @@ def database_app() -> Iterator[Flask]:
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_wraps():
|
||||
wraps_features = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_email_password_login=True,
|
||||
)
|
||||
wraps_features = SystemFeatureModel(enable_email_password_login=True)
|
||||
with (
|
||||
patch("controllers.console.wraps.db") as mock_db,
|
||||
patch("controllers.console.wraps.dify_config.ENTERPRISE_ENABLED", True),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from extensions.ext_celery import _enqueue_initial_community_telemetry_heartbeat
|
||||
|
||||
|
||||
def test_beat_start_enqueues_community_telemetry_heartbeat() -> None:
|
||||
task = Mock()
|
||||
sender = SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
conf=SimpleNamespace(beat_schedule={"community_telemetry_heartbeat": {}}),
|
||||
tasks={"community_telemetry.send_heartbeat": task},
|
||||
)
|
||||
)
|
||||
|
||||
_enqueue_initial_community_telemetry_heartbeat(sender)
|
||||
|
||||
task.apply_async.assert_called_once_with()
|
||||
|
||||
|
||||
def test_beat_start_skips_community_telemetry_when_not_scheduled() -> None:
|
||||
task = Mock()
|
||||
sender = SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
conf=SimpleNamespace(beat_schedule={}),
|
||||
tasks={"community_telemetry.send_heartbeat": task},
|
||||
)
|
||||
)
|
||||
|
||||
_enqueue_initial_community_telemetry_heartbeat(sender)
|
||||
|
||||
task.apply_async.assert_not_called()
|
||||
|
||||
|
||||
def test_beat_start_skips_community_telemetry_when_task_is_unavailable() -> None:
|
||||
sender = SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
conf=SimpleNamespace(beat_schedule={"community_telemetry_heartbeat": {}}),
|
||||
tasks={},
|
||||
)
|
||||
)
|
||||
|
||||
_enqueue_initial_community_telemetry_heartbeat(sender)
|
||||
@@ -135,38 +135,6 @@ def test_get_published_agent_soul_for_app_returns_none_without_backing_agent():
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_peek_authz_app_id_uses_the_parent_app_not_the_hidden_backing_app():
|
||||
"""A workflow-only Agent is authorized against its parent workflow App."""
|
||||
agent = SimpleNamespace(id="agent-1", backing_app_id="backing-app-1", app_id="parent-app-1")
|
||||
service = AgentRosterService(FakeSession(scalar=[agent]))
|
||||
|
||||
result = service.peek_authz_app_id(tenant_id="tenant-1", agent_id="agent-1")
|
||||
|
||||
assert result == "parent-app-1"
|
||||
|
||||
|
||||
def test_peek_authz_app_id_uses_the_roster_agent_app():
|
||||
agent = SimpleNamespace(id="agent-1", backing_app_id=None, app_id="roster-app-1")
|
||||
service = AgentRosterService(FakeSession(scalar=[agent]))
|
||||
|
||||
result = service.peek_authz_app_id(tenant_id="tenant-1", agent_id="agent-1")
|
||||
|
||||
assert result == "roster-app-1"
|
||||
|
||||
|
||||
def test_peek_authz_app_id_returns_none_without_creating_a_backing_app():
|
||||
"""Authorization checks must not materialize the hidden backing App."""
|
||||
session = FakeSession(scalar=[None])
|
||||
service = AgentRosterService(session)
|
||||
|
||||
result = service.peek_authz_app_id(tenant_id="tenant-1", agent_id="agent-1")
|
||||
|
||||
assert result is None
|
||||
assert session.added == []
|
||||
assert session.commits == 0
|
||||
assert session.flushes == 0
|
||||
|
||||
|
||||
def test_load_workflow_composer_returns_empty_state(monkeypatch: pytest.MonkeyPatch):
|
||||
session = FakeSession()
|
||||
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1"))
|
||||
|
||||
@@ -933,20 +933,3 @@ class TestListOption:
|
||||
"page_number": 1,
|
||||
"resource_type": "app",
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -24,7 +24,6 @@ from models.engine import db
|
||||
from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider
|
||||
from services.errors.plugin import PluginInstallationForbiddenError
|
||||
from services.feature_service import (
|
||||
DeploymentEdition,
|
||||
PluginInstallationPermissionModel,
|
||||
PluginInstallationScope,
|
||||
SystemFeatureModel,
|
||||
@@ -36,11 +35,10 @@ def _make_features(
|
||||
scope: PluginInstallationScope = PluginInstallationScope.ALL,
|
||||
) -> SystemFeatureModel:
|
||||
return SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
plugin_installation_permission=PluginInstallationPermissionModel(
|
||||
restrict_to_marketplace_only=restrict_to_marketplace,
|
||||
plugin_installation_scope=scope,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event, select
|
||||
@@ -1329,7 +1330,10 @@ class TestRegisterService:
|
||||
with patch("services.account_service.AccountService.create_account") as mock_create_account:
|
||||
mock_create_account.return_value = mock_account
|
||||
|
||||
with patch("services.account_service.TenantService.create_owner_tenant_if_not_exist") as mock_create_tenant:
|
||||
with (
|
||||
patch("services.account_service.TenantService.create_owner_tenant_if_not_exist") as mock_create_tenant,
|
||||
patch("services.account_service.CommunityTelemetryService.report_install") as mock_report_install,
|
||||
):
|
||||
RegisterService.setup(
|
||||
"admin@example.com",
|
||||
"Admin User",
|
||||
@@ -1348,7 +1352,39 @@ class TestRegisterService:
|
||||
session=sqlite_session,
|
||||
)
|
||||
mock_create_tenant.assert_called_once_with(account=mock_account, is_setup=True, session=sqlite_session)
|
||||
assert sqlite_session.scalar(select(DifySetup)) is not None
|
||||
dify_setup = sqlite_session.scalar(select(DifySetup))
|
||||
assert dify_setup is not None
|
||||
assert dify_setup.instance_id is not None
|
||||
assert str(UUID(dify_setup.instance_id)) == dify_setup.instance_id
|
||||
assert dify_setup.install_reported_at is None
|
||||
assert dify_setup.last_heartbeat_at is None
|
||||
mock_report_install.assert_called_once_with(session=sqlite_session)
|
||||
|
||||
def test_setup_succeeds_when_telemetry_install_report_fails(
|
||||
self, sqlite_session: Session, mock_external_service_dependencies
|
||||
):
|
||||
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
|
||||
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
|
||||
mock_account = TestAccountAssociatedDataFactory.create_account_mock()
|
||||
|
||||
with (
|
||||
patch("services.account_service.AccountService.create_account", return_value=mock_account),
|
||||
patch("services.account_service.TenantService.create_owner_tenant_if_not_exist"),
|
||||
patch(
|
||||
"services.account_service.CommunityTelemetryService.report_install",
|
||||
side_effect=RuntimeError("telemetry unavailable"),
|
||||
),
|
||||
):
|
||||
RegisterService.setup(
|
||||
"admin@example.com",
|
||||
"Admin User",
|
||||
"password123",
|
||||
"192.168.1.1",
|
||||
"en-US",
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert sqlite_session.scalar(select(DifySetup)) is not None
|
||||
|
||||
def test_setup_failure_rollback(self, sqlite_session: Session, mock_external_service_dependencies):
|
||||
"""Test setup failure with proper rollback."""
|
||||
|
||||
@@ -5,12 +5,10 @@ from unittest.mock import Mock
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.rbac import RBACPermission
|
||||
from models import App, AppMode
|
||||
from models.model import AppModelConfig, IconType
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.entities.dsl_entities import ImportStatus
|
||||
from services.errors.account import NoPermissionError
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
@@ -168,61 +166,3 @@ def test_export_dsl_loads_model_config_and_annotation_reply_with_request_session
|
||||
session.get.assert_called_once_with(AppModelConfig, "config-1")
|
||||
load_annotation_reply_config.assert_called_once_with(session, "app-1")
|
||||
app_model_config.to_dict.assert_called_once_with(annotation_reply=annotation_reply)
|
||||
|
||||
|
||||
def test_ensure_agent_manage_permission_noops_when_rbac_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", False)
|
||||
check = Mock()
|
||||
monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", check)
|
||||
|
||||
AppDslService._ensure_agent_manage_permission(Mock(id="account-1", current_tenant_id="tenant-1"))
|
||||
|
||||
check.assert_not_called()
|
||||
|
||||
|
||||
def test_ensure_agent_manage_permission_allows_agent_manager(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True)
|
||||
check = Mock(return_value=True)
|
||||
monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", check)
|
||||
|
||||
AppDslService._ensure_agent_manage_permission(Mock(id="account-1", current_tenant_id="tenant-1"))
|
||||
|
||||
check.assert_called_once_with("tenant-1", "account-1", scene=RBACPermission.AGENT_MANAGE)
|
||||
|
||||
|
||||
def test_ensure_agent_manage_permission_rejects_without_agent_manage(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True)
|
||||
monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", Mock(return_value=False))
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
AppDslService._ensure_agent_manage_permission(Mock(id="account-1", current_tenant_id="tenant-1"))
|
||||
|
||||
|
||||
def test_create_or_update_app_gates_agent_mode_before_creation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True)
|
||||
monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", Mock(return_value=False))
|
||||
session = Mock()
|
||||
service = AppDslService(session=session)
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
service._create_or_update_app(
|
||||
app=None,
|
||||
data={"app": {"mode": "agent", "name": "Gated agent"}},
|
||||
account=Mock(id="account-1", current_tenant_id="tenant-1"),
|
||||
)
|
||||
|
||||
session.add.assert_not_called()
|
||||
session.flush.assert_not_called()
|
||||
|
||||
|
||||
def test_import_app_reraises_permission_denial_instead_of_failed_result(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True)
|
||||
monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", Mock(return_value=False))
|
||||
service = AppDslService(session=Mock())
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
service.import_app(
|
||||
account=Mock(id="account-1", current_tenant_id="tenant-1"),
|
||||
import_mode="yaml-content",
|
||||
yaml_content="app:\n mode: agent\n name: Denied agent\n",
|
||||
)
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [False, True])
|
||||
def test_get_system_features_reads_enable_change_email(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
enabled: bool,
|
||||
) -> None:
|
||||
monkeypatch.setattr("services.feature_service.dify_config.ENABLE_CHANGE_EMAIL", enabled)
|
||||
|
||||
result = FeatureService.get_system_features()
|
||||
|
||||
assert result.enable_change_email is enabled
|
||||
|
||||
|
||||
def test_enterprise_disables_change_email(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("services.feature_service.dify_config.ENABLE_CHANGE_EMAIL", True)
|
||||
monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", True)
|
||||
monkeypatch.setattr("services.feature_service.FeatureService._fulfill_params_from_enterprise", lambda *_: None)
|
||||
|
||||
result = FeatureService.get_system_features()
|
||||
|
||||
assert result.enable_change_email is False
|
||||
@@ -1,34 +0,0 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from services.feature_service import DeploymentEdition, FeatureService, SystemFeatureModel
|
||||
|
||||
|
||||
def test_system_feature_model_requires_deployment_edition() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
SystemFeatureModel.model_validate({})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("edition", "enterprise_enabled", "expected"),
|
||||
[
|
||||
("SELF_HOSTED", False, DeploymentEdition.COMMUNITY),
|
||||
("SELF_HOSTED", True, DeploymentEdition.ENTERPRISE),
|
||||
("CLOUD", False, DeploymentEdition.CLOUD),
|
||||
("CLOUD", True, DeploymentEdition.CLOUD),
|
||||
],
|
||||
)
|
||||
def test_get_system_features_resolves_deployment_edition(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
edition: str,
|
||||
enterprise_enabled: bool,
|
||||
expected: DeploymentEdition,
|
||||
) -> None:
|
||||
monkeypatch.setattr("services.feature_service.dify_config.EDITION", edition)
|
||||
monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", enterprise_enabled)
|
||||
monkeypatch.setattr("services.feature_service.FeatureService._fulfill_params_from_enterprise", lambda *_: None)
|
||||
|
||||
result = FeatureService.get_system_features()
|
||||
|
||||
assert result.deployment_edition is expected
|
||||
assert result.model_dump(mode="json")["deployment_edition"] == expected.value
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from services import feature_service as feature_service_module
|
||||
from services.feature_service import DeploymentEdition, FeatureService, SystemFeatureModel
|
||||
from services.feature_service import FeatureService, SystemFeatureModel
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -29,7 +29,7 @@ def test_fulfill_params_from_enterprise_enable_app_deploy(
|
||||
staticmethod(lambda: enterprise_info),
|
||||
)
|
||||
|
||||
features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY)
|
||||
features = SystemFeatureModel()
|
||||
features.enable_app_deploy = initial
|
||||
|
||||
FeatureService._fulfill_params_from_enterprise(features)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import pytest
|
||||
|
||||
from services.feature_service import DeploymentEdition, FeatureService, SystemFeatureModel
|
||||
from services.feature_service import FeatureService, SystemFeatureModel
|
||||
|
||||
|
||||
def test_system_feature_model_disables_knowledge_fs_by_default() -> None:
|
||||
assert SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY).knowledge_fs_enabled is False
|
||||
assert SystemFeatureModel().knowledge_fs_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [False, True])
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import pytest
|
||||
|
||||
from services import feature_service as feature_service_module
|
||||
from services.feature_service import DeploymentEdition, FeatureService, SystemFeatureModel
|
||||
from services.feature_service import FeatureService, SystemFeatureModel
|
||||
|
||||
|
||||
def test_system_feature_model_defaults_enable_learn_app():
|
||||
system_features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY)
|
||||
|
||||
assert system_features.enable_learn_app is True
|
||||
assert system_features.enable_step_by_step_tour is False
|
||||
assert SystemFeatureModel().enable_learn_app is True
|
||||
assert SystemFeatureModel().enable_step_by_step_tour is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [True, False])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from services import feature_service as feature_service_module
|
||||
from services.feature_service import DeploymentEdition, FeatureService, SystemFeatureModel
|
||||
from services.feature_service import FeatureService, SystemFeatureModel
|
||||
|
||||
_ENTERPRISE_INFO = {"License": {"licensedSeats": {"enabled": True, "limit": 3, "used": 1}}}
|
||||
|
||||
@@ -14,7 +14,7 @@ def test_fulfill_params_from_enterprise_parses_licensed_seats(monkeypatch: pytes
|
||||
staticmethod(lambda: _ENTERPRISE_INFO),
|
||||
)
|
||||
|
||||
features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY)
|
||||
features = SystemFeatureModel()
|
||||
FeatureService._fulfill_params_from_enterprise(features, is_authenticated=True)
|
||||
|
||||
assert features.license.seats.enabled is True
|
||||
@@ -30,7 +30,7 @@ def test_fulfill_params_from_enterprise_withholds_seats_when_unauthenticated(mon
|
||||
staticmethod(lambda: _ENTERPRISE_INFO),
|
||||
)
|
||||
|
||||
features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY)
|
||||
features = SystemFeatureModel()
|
||||
FeatureService._fulfill_params_from_enterprise(features, is_authenticated=False)
|
||||
|
||||
assert features.license.seats.enabled is False
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from services.feature_service import DeploymentEdition, FeatureService, SystemFeatureModel
|
||||
from services.feature_service import FeatureService, SystemFeatureModel
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -18,7 +18,7 @@ def test_fulfill_system_params_from_env_sets_allow_public_access(
|
||||
):
|
||||
monkeypatch.setattr("services.feature_service.dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED", env_value)
|
||||
|
||||
system_features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY)
|
||||
system_features = SystemFeatureModel()
|
||||
FeatureService._fulfill_system_params_from_env(system_features)
|
||||
|
||||
assert system_features.webapp_auth.allow_public_access is expected
|
||||
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from models.model import AccountTrialAppRecord, App, AppMode, TrialApp
|
||||
from services import recommended_app_service as service_module
|
||||
from services.feature_service import DeploymentEdition, SystemFeatureModel
|
||||
from services.feature_service import SystemFeatureModel
|
||||
from services.recommended_app_service import RecommendedAppService
|
||||
|
||||
pytestmark = pytest.mark.parametrize(
|
||||
@@ -298,10 +298,7 @@ class TestRecommendedAppServiceGetDetail:
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
|
||||
mock_feature_service.get_system_features.return_value = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_trial_app=False,
|
||||
)
|
||||
mock_feature_service.get_system_features.return_value = SystemFeatureModel(enable_trial_app=False)
|
||||
cases: list[tuple[str, RecommendedAppPayload]] = [
|
||||
(
|
||||
"complex-app",
|
||||
@@ -340,10 +337,7 @@ class TestRecommendedAppServiceGetDetail:
|
||||
mock_feature_service: MagicMock,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
mock_feature_service.get_system_features.return_value = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_trial_app=False,
|
||||
)
|
||||
mock_feature_service.get_system_features.return_value = SystemFeatureModel(enable_trial_app=False)
|
||||
for mode in ["remote", "builtin", "db"]:
|
||||
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = mode
|
||||
detail = _app_detail(app_id="test-app", name=f"App from {mode}")
|
||||
@@ -373,10 +367,7 @@ class TestRecommendedAppServiceGetLearnDifyApps:
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
|
||||
mock_feature_service.get_system_features.return_value = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_trial_app=False,
|
||||
)
|
||||
mock_feature_service.get_system_features.return_value = SystemFeatureModel(enable_trial_app=False)
|
||||
expected_app = RecommendedAppPayload(app_id="app-1", category="Workflow")
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.get_learn_dify_apps.return_value = {
|
||||
@@ -411,12 +402,7 @@ class TestRecommendedAppServiceGetLearnDifyApps:
|
||||
monkeypatch.setattr(
|
||||
service_module.FeatureService,
|
||||
"get_system_features",
|
||||
MagicMock(
|
||||
return_value=SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_trial_app=True,
|
||||
)
|
||||
),
|
||||
MagicMock(return_value=SystemFeatureModel(enable_trial_app=True)),
|
||||
)
|
||||
can_trial_mock = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(RecommendedAppService, "_can_trial_app", can_trial_mock)
|
||||
@@ -439,12 +425,7 @@ class TestRecommendedAppServiceTrialFeatures:
|
||||
monkeypatch.setattr(
|
||||
service_module.FeatureService,
|
||||
"get_system_features",
|
||||
MagicMock(
|
||||
return_value=SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_trial_app=False,
|
||||
)
|
||||
),
|
||||
MagicMock(return_value=SystemFeatureModel(enable_trial_app=False)),
|
||||
)
|
||||
|
||||
result = RecommendedAppService.get_recommended_apps_and_categories("en-US", session=sqlite_session)
|
||||
@@ -475,12 +456,7 @@ class TestRecommendedAppServiceTrialFeatures:
|
||||
monkeypatch.setattr(
|
||||
service_module.FeatureService,
|
||||
"get_system_features",
|
||||
MagicMock(
|
||||
return_value=SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_trial_app=True,
|
||||
)
|
||||
),
|
||||
MagicMock(return_value=SystemFeatureModel(enable_trial_app=True)),
|
||||
)
|
||||
|
||||
result = RecommendedAppService.get_recommended_apps_and_categories("ja-JP", session=sqlite_session)
|
||||
@@ -516,12 +492,7 @@ class TestRecommendedAppServiceTrialFeatures:
|
||||
monkeypatch.setattr(
|
||||
service_module.FeatureService,
|
||||
"get_system_features",
|
||||
MagicMock(
|
||||
return_value=SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_trial_app=True,
|
||||
)
|
||||
),
|
||||
MagicMock(return_value=SystemFeatureModel(enable_trial_app=True)),
|
||||
)
|
||||
|
||||
result = RecommendedAppService.get_recommend_app_detail(app_id, session=sqlite_session)
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.model import DifySetup
|
||||
from services import telemetry_service
|
||||
from services.telemetry_service import CommunityTelemetryService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def telemetry_enabled(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "EDITION", "SELF_HOSTED")
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "ENTERPRISE_ENABLED", False)
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "DISABLE_TELEMETRY", False)
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "DO_NOT_TRACK", False)
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "CI", False)
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "TELEMETRY_ENDPOINT", "https://telemetry.example.test/v1/events")
|
||||
monkeypatch.setattr(
|
||||
telemetry_service.dify_config,
|
||||
"TELEMETRY_FALLBACK_ENDPOINT",
|
||||
"https://telemetry-cn.example.test/v1/events",
|
||||
)
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "TELEMETRY_TIMEOUT_SECONDS", 2)
|
||||
|
||||
|
||||
def test_telemetry_is_disabled_for_enterprise(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "EDITION", "SELF_HOSTED")
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "ENTERPRISE_ENABLED", True)
|
||||
|
||||
assert CommunityTelemetryService._is_enabled() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("setting", "value"),
|
||||
[
|
||||
("EDITION", "CLOUD"),
|
||||
("DISABLE_TELEMETRY", True),
|
||||
("DO_NOT_TRACK", True),
|
||||
("CI", True),
|
||||
("TELEMETRY_ENDPOINT", ""),
|
||||
],
|
||||
)
|
||||
def test_telemetry_is_disabled_when_a_required_condition_is_not_met(
|
||||
telemetry_enabled, monkeypatch: pytest.MonkeyPatch, setting: str, value: str | bool
|
||||
):
|
||||
monkeypatch.setattr(telemetry_service.dify_config, setting, value)
|
||||
|
||||
assert CommunityTelemetryService._is_enabled() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_reporting_without_setup_is_skipped(sqlite_session: Session, telemetry_enabled):
|
||||
assert CommunityTelemetryService.report_install(session=sqlite_session) is False
|
||||
assert CommunityTelemetryService.report_heartbeat(session=sqlite_session) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_install_marks_reported_at(sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch):
|
||||
setup = DifySetup(version="installed-version", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
monkeypatch.setattr(telemetry_service.dify_config.project, "version", "running-version")
|
||||
|
||||
sent_payloads: list[dict[str, str | int]] = []
|
||||
|
||||
def fake_post(url: str, json: dict[str, str | int], timeout: int):
|
||||
sent_payloads.append(json)
|
||||
return httpx.Response(204, request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
|
||||
|
||||
assert CommunityTelemetryService.report_install(session=sqlite_session) is True
|
||||
|
||||
saved_setup = sqlite_session.scalar(select(DifySetup))
|
||||
assert saved_setup is not None
|
||||
assert saved_setup.install_reported_at is not None
|
||||
assert sent_payloads[0]["event"] == "install"
|
||||
assert sent_payloads[0]["instance_id"] == setup.instance_id
|
||||
assert sent_payloads[0]["version"] == "installed-version"
|
||||
assert "installed_at" in sent_payloads[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_install_generates_missing_instance_id(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(version="installed-version")
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
monkeypatch.setattr(
|
||||
telemetry_service.httpx,
|
||||
"post",
|
||||
lambda url, json, timeout: httpx.Response(204, request=httpx.Request("POST", url)),
|
||||
)
|
||||
|
||||
assert CommunityTelemetryService.report_install(session=sqlite_session) is True
|
||||
|
||||
assert setup.instance_id is not None
|
||||
assert str(uuid.UUID(setup.instance_id)) == setup.instance_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_heartbeat_generates_missing_instance_id(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(version="1.0.0", install_reported_at=datetime(2026, 7, 12, 8, 0, 0))
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
monkeypatch.setattr(
|
||||
telemetry_service.httpx,
|
||||
"post",
|
||||
lambda url, json, timeout: httpx.Response(204, request=httpx.Request("POST", url)),
|
||||
)
|
||||
|
||||
assert CommunityTelemetryService.report_heartbeat(
|
||||
session=sqlite_session, now=datetime(2026, 7, 13, 12, 0, 0)
|
||||
) is True
|
||||
|
||||
assert setup.instance_id is not None
|
||||
assert str(uuid.UUID(setup.instance_id)) == setup.instance_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_install_failure_keeps_install_pending(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(version="1.0.0", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
|
||||
def fake_post(url: str, json: dict[str, str | int], timeout: int):
|
||||
raise httpx.ConnectError("offline", request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
|
||||
|
||||
assert CommunityTelemetryService.report_install(session=sqlite_session) is False
|
||||
|
||||
saved_setup = sqlite_session.scalar(select(DifySetup))
|
||||
assert saved_setup is not None
|
||||
assert saved_setup.install_reported_at is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_install_uses_fallback_endpoint_after_network_failure(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(version="1.0.0", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
|
||||
urls: list[str] = []
|
||||
|
||||
def fake_post(url: str, json: dict[str, str | int], timeout: int):
|
||||
urls.append(url)
|
||||
if url == telemetry_service.dify_config.TELEMETRY_ENDPOINT:
|
||||
raise httpx.ConnectError("offline", request=httpx.Request("POST", url))
|
||||
return httpx.Response(204, request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
|
||||
|
||||
assert CommunityTelemetryService.report_install(session=sqlite_session) is True
|
||||
assert urls == [
|
||||
telemetry_service.dify_config.TELEMETRY_ENDPOINT,
|
||||
telemetry_service.dify_config.TELEMETRY_FALLBACK_ENDPOINT,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_install_does_not_use_fallback_endpoint_after_http_error(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(version="1.0.0", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
|
||||
post_mock = Mock(
|
||||
return_value=httpx.Response(
|
||||
500,
|
||||
request=httpx.Request("POST", telemetry_service.dify_config.TELEMETRY_ENDPOINT),
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", post_mock)
|
||||
|
||||
assert CommunityTelemetryService.report_install(session=sqlite_session) is False
|
||||
post_mock.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_heartbeat_retries_pending_install_before_heartbeat(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(version="installed-version", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
monkeypatch.setattr(telemetry_service.dify_config.project, "version", "running-version")
|
||||
|
||||
sent_payloads: list[dict[str, str | int]] = []
|
||||
|
||||
def fake_post(url: str, json: dict[str, str | int], timeout: int):
|
||||
sent_payloads.append(json)
|
||||
return httpx.Response(204, request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
|
||||
now = datetime(2026, 7, 13, 0, 0, 0)
|
||||
assert CommunityTelemetryService.report_heartbeat(session=sqlite_session, now=now) is True
|
||||
|
||||
saved_setup = sqlite_session.scalar(select(DifySetup))
|
||||
assert saved_setup is not None
|
||||
assert saved_setup.install_reported_at is not None
|
||||
assert saved_setup.last_heartbeat_at == now
|
||||
assert [(payload["event"], payload["version"]) for payload in sent_payloads] == [
|
||||
("install", "installed-version"),
|
||||
("heartbeat", "running-version"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_heartbeat_skips_when_already_sent_today(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(
|
||||
version="1.0.0",
|
||||
instance_id="d246c3a1-350b-406c-92c7-6043df680758",
|
||||
install_reported_at=datetime(2026, 7, 13, 8, 0, 0),
|
||||
last_heartbeat_at=datetime(2026, 7, 13, 9, 0, 0),
|
||||
)
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
|
||||
post_mock = Mock()
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", post_mock)
|
||||
|
||||
assert (
|
||||
CommunityTelemetryService.report_heartbeat(session=sqlite_session, now=datetime(2026, 7, 13, 12, 0, 0)) is False
|
||||
)
|
||||
post_mock.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_heartbeat_failure_does_not_mark_the_day_reported(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(
|
||||
version="1.0.0",
|
||||
instance_id="d246c3a1-350b-406c-92c7-6043df680758",
|
||||
install_reported_at=datetime(2026, 7, 13, 8, 0, 0),
|
||||
)
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
|
||||
def fake_post(url: str, json: dict[str, str | int], timeout: int):
|
||||
raise httpx.ConnectError("offline", request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
|
||||
|
||||
assert CommunityTelemetryService.report_heartbeat(
|
||||
session=sqlite_session, now=datetime(2026, 7, 13, 12, 0, 0)
|
||||
) is False
|
||||
assert setup.last_heartbeat_at is None
|
||||
|
||||
|
||||
def test_send_event_skips_when_telemetry_is_disabled(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "DISABLE_TELEMETRY", True)
|
||||
post_mock = Mock()
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", post_mock)
|
||||
|
||||
assert CommunityTelemetryService._send_event({"event": "heartbeat"}) is False
|
||||
post_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_send_event_skips_an_empty_fallback_endpoint(telemetry_enabled, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "TELEMETRY_FALLBACK_ENDPOINT", "")
|
||||
|
||||
def fake_post(url: str, json: dict[str, str], timeout: int):
|
||||
raise httpx.ConnectError("offline", request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
|
||||
|
||||
assert CommunityTelemetryService._send_event({"event": "heartbeat"}) is False
|
||||
|
||||
|
||||
def test_send_event_does_not_retry_the_same_endpoint(telemetry_enabled, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
telemetry_service.dify_config,
|
||||
"TELEMETRY_FALLBACK_ENDPOINT",
|
||||
telemetry_service.dify_config.TELEMETRY_ENDPOINT,
|
||||
)
|
||||
post_mock = Mock(
|
||||
return_value=httpx.Response(
|
||||
204,
|
||||
request=httpx.Request("POST", telemetry_service.dify_config.TELEMETRY_ENDPOINT),
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", post_mock)
|
||||
|
||||
assert CommunityTelemetryService._send_event({"event": "heartbeat"}) is True
|
||||
post_mock.assert_called_once()
|
||||
|
||||
|
||||
def test_heartbeat_is_not_due_without_instance_id():
|
||||
setup = DifySetup(version="1.0.0")
|
||||
|
||||
assert CommunityTelemetryService._is_heartbeat_due(setup, datetime(2026, 7, 13, 12, 0, 0)) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("Linux", "linux"),
|
||||
("Plan9", "unknown"),
|
||||
],
|
||||
)
|
||||
def test_normalize_os(value: str, expected: str):
|
||||
assert CommunityTelemetryService._normalize_os(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("x86_64", "amd64"),
|
||||
("aarch64", "arm64"),
|
||||
("armv7l", "arm"),
|
||||
("i686", "386"),
|
||||
("riscv64", "unknown"),
|
||||
],
|
||||
)
|
||||
def test_normalize_arch(value: str, expected: str):
|
||||
assert CommunityTelemetryService._normalize_arch(value) == expected
|
||||
@@ -0,0 +1,40 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from tasks import community_telemetry_task
|
||||
|
||||
|
||||
def _configure_task_session(monkeypatch: pytest.MonkeyPatch) -> Mock:
|
||||
session = Mock()
|
||||
session_factory = MagicMock()
|
||||
session_factory.return_value.__enter__.return_value = session
|
||||
monkeypatch.setattr(community_telemetry_task, "db", SimpleNamespace(engine=object()))
|
||||
monkeypatch.setattr(community_telemetry_task, "sessionmaker", Mock(return_value=session_factory))
|
||||
return session
|
||||
|
||||
|
||||
def test_send_community_telemetry_heartbeat_reports_with_a_database_session(monkeypatch: pytest.MonkeyPatch):
|
||||
session = _configure_task_session(monkeypatch)
|
||||
report_heartbeat = Mock()
|
||||
monkeypatch.setattr(community_telemetry_task.CommunityTelemetryService, "report_heartbeat", report_heartbeat)
|
||||
|
||||
community_telemetry_task.send_community_telemetry_heartbeat.run()
|
||||
|
||||
report_heartbeat.assert_called_once_with(session=session)
|
||||
|
||||
|
||||
def test_send_community_telemetry_heartbeat_swallows_report_errors(monkeypatch: pytest.MonkeyPatch):
|
||||
_configure_task_session(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
community_telemetry_task.CommunityTelemetryService,
|
||||
"report_heartbeat",
|
||||
Mock(side_effect=RuntimeError("telemetry unavailable")),
|
||||
)
|
||||
log_debug = Mock()
|
||||
monkeypatch.setattr(community_telemetry_task.logger, "debug", log_debug)
|
||||
|
||||
community_telemetry_task.send_community_telemetry_heartbeat.run()
|
||||
|
||||
log_debug.assert_called_once_with("Failed to process community telemetry heartbeat", exc_info=True)
|
||||
@@ -16,21 +16,11 @@ CHECK_UPDATE_URL=https://updates.dify.ai
|
||||
OPENAI_API_BASE=https://api.openai.com/v1
|
||||
MIGRATION_ENABLED=true
|
||||
FILES_ACCESS_TIMEOUT=300
|
||||
# System Features
|
||||
MARKETPLACE_ENABLED=true
|
||||
ENABLE_EMAIL_CODE_LOGIN=false
|
||||
ENABLE_EMAIL_PASSWORD_LOGIN=true
|
||||
ENABLE_SOCIAL_OAUTH_LOGIN=false
|
||||
# Remove `collaboration` from COMPOSE_PROFILES to stop the dedicated websocket service.
|
||||
ENABLE_COLLABORATION_MODE=true
|
||||
ALLOW_REGISTER=false
|
||||
ALLOW_CREATE_WORKSPACE=false
|
||||
ENABLE_CHANGE_EMAIL=true
|
||||
ENABLE_TRIAL_APP=false
|
||||
ENABLE_EXPLORE_BANNER=false
|
||||
|
||||
# Learn app feature toggle
|
||||
ENABLE_LEARN_APP=true
|
||||
ENABLE_STEP_BY_STEP_TOUR=false
|
||||
RBAC_ENABLED=false
|
||||
CELERY_BROKER_URL=redis://:difyai123456@redis:6379/1
|
||||
CELERY_TASK_ANNOTATIONS=null
|
||||
AZURE_BLOB_ACCOUNT_URL=https://<your_account_name>.blob.core.windows.net
|
||||
@@ -100,8 +90,6 @@ WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS=
|
||||
EXPOSE_PLUGIN_DEBUGGING_HOST=localhost
|
||||
EXPOSE_PLUGIN_DEBUGGING_PORT=5003
|
||||
DEPLOY_ENV=PRODUCTION
|
||||
EDITION=SELF_HOSTED
|
||||
ENTERPRISE_ENABLED=false
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
APP_DEFAULT_ACTIVE_REQUESTS=0
|
||||
|
||||
@@ -15,6 +15,7 @@ TEXT_GENERATION_TIMEOUT_MS=60000
|
||||
ALLOW_INLINE_STYLES=false
|
||||
ALLOW_UNSAFE_DATA_SCHEME=false
|
||||
MAX_TREE_DEPTH=50
|
||||
MARKETPLACE_ENABLED=true
|
||||
MARKETPLACE_API_URL=https://marketplace.dify.ai
|
||||
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000
|
||||
ALLOW_EMBED=false
|
||||
|
||||
@@ -6,7 +6,6 @@ export type ClientOptions = {
|
||||
|
||||
export type SystemFeatureModel = {
|
||||
branding: BrandingModel
|
||||
deployment_edition: DeploymentEdition
|
||||
enable_app_deploy: boolean
|
||||
enable_change_email: boolean
|
||||
enable_collaboration_mode: boolean
|
||||
@@ -41,8 +40,6 @@ export type BrandingModel = {
|
||||
workspace_logo: string
|
||||
}
|
||||
|
||||
export type DeploymentEdition = 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE'
|
||||
|
||||
export type LicenseModel = {
|
||||
expired_at: string
|
||||
seats: LicenseLimitationModel
|
||||
|
||||
@@ -13,11 +13,6 @@ export const zBrandingModel = z.object({
|
||||
workspace_logo: z.string().default(''),
|
||||
})
|
||||
|
||||
/**
|
||||
* DeploymentEdition
|
||||
*/
|
||||
export const zDeploymentEdition = z.enum(['CLOUD', 'COMMUNITY', 'ENTERPRISE'])
|
||||
|
||||
/**
|
||||
* PluginManagerModel
|
||||
*/
|
||||
@@ -109,7 +104,6 @@ export const zSystemFeatureModel = z.object({
|
||||
login_page_logo: '',
|
||||
workspace_logo: '',
|
||||
}),
|
||||
deployment_edition: zDeploymentEdition,
|
||||
enable_app_deploy: z.boolean().default(false),
|
||||
enable_change_email: z.boolean().default(true),
|
||||
enable_collaboration_mode: z.boolean().default(true),
|
||||
|
||||
@@ -118,8 +118,6 @@ export type ConversationRenamePayload = (
|
||||
name?: string | null
|
||||
}
|
||||
|
||||
export type DeploymentEdition = 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE'
|
||||
|
||||
export type EmailCodeLoginSendPayload = {
|
||||
email: string
|
||||
language?: string | null
|
||||
@@ -512,7 +510,6 @@ export type SuggestedQuestionsResponse = {
|
||||
|
||||
export type SystemFeatureModel = {
|
||||
branding: BrandingModel
|
||||
deployment_edition: DeploymentEdition
|
||||
enable_app_deploy: boolean
|
||||
enable_change_email: boolean
|
||||
enable_collaboration_mode: boolean
|
||||
|
||||
@@ -132,11 +132,6 @@ export const zConversationRenamePayload = z.intersection(
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* DeploymentEdition
|
||||
*/
|
||||
export const zDeploymentEdition = z.enum(['CLOUD', 'COMMUNITY', 'ENTERPRISE'])
|
||||
|
||||
/**
|
||||
* EmailCodeLoginSendPayload
|
||||
*/
|
||||
@@ -787,7 +782,6 @@ export const zSystemFeatureModel = z.object({
|
||||
login_page_logo: '',
|
||||
workspace_logo: '',
|
||||
}),
|
||||
deployment_edition: zDeploymentEdition,
|
||||
enable_app_deploy: z.boolean().default(false),
|
||||
enable_change_email: z.boolean().default(true),
|
||||
enable_collaboration_mode: z.boolean().default(true),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# For production release, change this to PRODUCTION
|
||||
NEXT_PUBLIC_DEPLOY_ENV=DEVELOPMENT
|
||||
# The deployment edition, SELF_HOSTED
|
||||
NEXT_PUBLIC_EDITION=SELF_HOSTED
|
||||
# The base path for the application
|
||||
NEXT_PUBLIC_BASE_PATH=
|
||||
# Server-only console API origin for server-side requests.
|
||||
@@ -110,3 +112,20 @@ NEXT_PUBLIC_WEB_PREFIX=
|
||||
|
||||
# number of concurrency
|
||||
NEXT_PUBLIC_BATCH_CONCURRENCY=5
|
||||
|
||||
# Cloud system-features frontend defaults.
|
||||
# These values are only used when NEXT_PUBLIC_EDITION=CLOUD (IS_CLOUD_EDITION).
|
||||
NEXT_PUBLIC_ENABLE_MARKETPLACE=true
|
||||
NEXT_PUBLIC_ENABLE_EMAIL_CODE_LOGIN=true
|
||||
NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN=false
|
||||
NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN=true
|
||||
NEXT_PUBLIC_ENABLE_COLLABORATION_MODE=false
|
||||
NEXT_PUBLIC_ALLOW_REGISTER=true
|
||||
NEXT_PUBLIC_ALLOW_CREATE_WORKSPACE=true
|
||||
NEXT_PUBLIC_IS_EMAIL_SETUP=true
|
||||
NEXT_PUBLIC_ENABLE_CHANGE_EMAIL=true
|
||||
NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED=true
|
||||
NEXT_PUBLIC_ENABLE_TRIAL_APP=true
|
||||
NEXT_PUBLIC_ENABLE_EXPLORE_BANNER=true
|
||||
NEXT_PUBLIC_RBAC_ENABLED=false
|
||||
NEXT_PUBLIC_KNOWLEDGE_FS_ENABLED=false
|
||||
|
||||
@@ -49,6 +49,7 @@ RUN pnpm build && pnpm build:vinext
|
||||
FROM base AS production
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV EDITION=SELF_HOSTED
|
||||
ENV DEPLOY_ENV=PRODUCTION
|
||||
ENV CONSOLE_API_URL=http://127.0.0.1:5001
|
||||
ENV APP_API_URL=http://127.0.0.1:5001
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { RenderOptions } from '@testing-library/react'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@@ -16,21 +14,21 @@ import TriggerEventsLimitModal from '@/app/components/billing/trigger-events-lim
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { render as renderWithConsoleState } from '@/test/console/render'
|
||||
|
||||
const render = (ui: ReactElement, options: RenderOptions = {}) => {
|
||||
const { wrapper } = createConsoleQueryWrapper({
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
})
|
||||
return renderWithConsoleState(ui, { ...options, wrapper })
|
||||
}
|
||||
import { render } from '@/test/console/render'
|
||||
|
||||
let mockProviderCtx: Record<string, unknown> = {}
|
||||
let mockConsoleState: Record<string, unknown> = {}
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
...actual,
|
||||
IS_CLOUD_EDITION: true,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderCtx,
|
||||
}))
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { RenderOptions } from '@testing-library/react'
|
||||
import type { ReactElement } from 'react'
|
||||
/**
|
||||
* Integration test: Education Verification Flow
|
||||
*
|
||||
@@ -16,15 +14,7 @@ import * as React from 'react'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import PlanComp from '@/app/components/billing/plan'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { render as renderWithConsoleState } from '@/test/console/render'
|
||||
|
||||
const render = (ui: ReactElement, options: RenderOptions = {}) => {
|
||||
const { wrapper } = createConsoleQueryWrapper({
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
})
|
||||
return renderWithConsoleState(ui, { ...options, wrapper })
|
||||
}
|
||||
import { render } from '@/test/console/render'
|
||||
|
||||
// ─── Mock state ──────────────────────────────────────────────────────────────
|
||||
let mockProviderCtx: Record<string, unknown> = {}
|
||||
@@ -35,6 +25,14 @@ const mockRouterPush = vi.fn()
|
||||
const mockMutateAsync = vi.fn()
|
||||
const mockSetEducationVerifying = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
...actual,
|
||||
IS_CLOUD_EDITION: true,
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Context mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderCtx,
|
||||
|
||||
@@ -7,15 +7,11 @@
|
||||
* Covers URL param reading, cookie persistence, API bind on mount,
|
||||
* cookie cleanup after successful bind, and error handling for 400 status.
|
||||
*/
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react'
|
||||
import Cookies from 'js-cookie'
|
||||
import * as React from 'react'
|
||||
import usePSInfo from '@/app/components/billing/partner-stack/use-ps-info'
|
||||
import { PARTNER_STACK_CONFIG } from '@/config'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
|
||||
const render = (ui: React.ReactElement) =>
|
||||
renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' } })
|
||||
|
||||
// ─── Mock state ──────────────────────────────────────────────────────────────
|
||||
let mockSearchParams = new URLSearchParams()
|
||||
@@ -43,6 +39,7 @@ vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return {
|
||||
...actual,
|
||||
IS_CLOUD_EDITION: true,
|
||||
PARTNER_STACK_CONFIG: {
|
||||
cookieName: 'partner_stack_info',
|
||||
saveCookieDays: 90,
|
||||
@@ -291,7 +288,7 @@ describe('Partner Stack Flow', () => {
|
||||
|
||||
// ─── 4. PartnerStack Component Mount ────────────────────────────────────
|
||||
describe('PartnerStack component mount behavior', () => {
|
||||
it('should call saveOrUpdate and bind on mount', async () => {
|
||||
it('should call saveOrUpdate and bind on mount when IS_CLOUD_EDITION is true', async () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'mount-partner',
|
||||
ps_xid: 'mount-click',
|
||||
@@ -302,13 +299,18 @@ describe('Partner Stack Flow', () => {
|
||||
|
||||
render(<PartnerStack />)
|
||||
|
||||
// The component calls saveOrUpdate and bind in useEffect
|
||||
await waitFor(() => {
|
||||
// Bind should have been called
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
partnerKey: 'mount-partner',
|
||||
clickId: 'mount-click',
|
||||
})
|
||||
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
|
||||
})
|
||||
|
||||
// Cookie should have been saved (saveOrUpdate was called before bind)
|
||||
// After bind succeeds, cookie is removed
|
||||
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should render nothing (return null)', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
describe('env runtime transport', () => {
|
||||
const originalAgentV2Env = process.env.NEXT_PUBLIC_ENABLE_AGENT_V2
|
||||
const originalRbacEnv = process.env.NEXT_PUBLIC_RBAC_ENABLED
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -7,12 +8,17 @@ describe('env runtime transport', () => {
|
||||
vi.doUnmock('../utils/client')
|
||||
document.body.removeAttribute('data-enable-agent-v2')
|
||||
document.body.removeAttribute('data-enable-agent-v-2')
|
||||
document.body.removeAttribute('data-rbac-enabled')
|
||||
delete process.env.NEXT_PUBLIC_ENABLE_AGENT_V2
|
||||
delete process.env.NEXT_PUBLIC_RBAC_ENABLED
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (originalAgentV2Env === undefined) delete process.env.NEXT_PUBLIC_ENABLE_AGENT_V2
|
||||
else process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 = originalAgentV2Env
|
||||
|
||||
if (originalRbacEnv === undefined) delete process.env.NEXT_PUBLIC_RBAC_ENABLED
|
||||
else process.env.NEXT_PUBLIC_RBAC_ENABLED = originalRbacEnv
|
||||
})
|
||||
|
||||
it('should read NEXT_PUBLIC_ENABLE_AGENT_V2 from the browser runtime dataset key', async () => {
|
||||
@@ -23,6 +29,14 @@ describe('env runtime transport', () => {
|
||||
expect(env.NEXT_PUBLIC_ENABLE_AGENT_V2).toBe(true)
|
||||
})
|
||||
|
||||
it('should read NEXT_PUBLIC_RBAC_ENABLED from the browser runtime dataset key', async () => {
|
||||
document.body.setAttribute('data-rbac-enabled', 'true')
|
||||
|
||||
const { env } = await import('../env')
|
||||
|
||||
expect(env.NEXT_PUBLIC_RBAC_ENABLED).toBe(true)
|
||||
})
|
||||
|
||||
it('should emit the Agent v2 runtime dataset attribute from getDatasetMap on the server', async () => {
|
||||
process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 = 'true'
|
||||
|
||||
@@ -37,4 +51,18 @@ describe('env runtime transport', () => {
|
||||
expect(datasetMap['data-enable-agent-v2']).toBe(true)
|
||||
expect(datasetMap['data-enable-agent-v-2']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should emit the RBAC runtime dataset attribute from getDatasetMap on the server', async () => {
|
||||
process.env.NEXT_PUBLIC_RBAC_ENABLED = 'true'
|
||||
|
||||
vi.doMock('../utils/client', () => ({
|
||||
isClient: false,
|
||||
isServer: true,
|
||||
}))
|
||||
|
||||
const { getDatasetMap } = await import('../env')
|
||||
const datasetMap = getDatasetMap()
|
||||
|
||||
expect(datasetMap['data-rbac-enabled']).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSystemFeaturesFixture } from '@/test/console/system-features'
|
||||
import { ConsoleBootstrapGate } from '../console-bootstrap-gate'
|
||||
|
||||
const profileQueryKey = ['console', 'account', 'profile', 'get']
|
||||
@@ -27,9 +26,7 @@ const createDeferred = <T,>(): Deferred<T> => {
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
profileQuery: undefined as Deferred<{ id: string }> | undefined,
|
||||
systemFeaturesQuery: undefined as
|
||||
| Deferred<ReturnType<typeof createSystemFeaturesFixture>>
|
||||
| undefined,
|
||||
systemFeaturesQuery: undefined as Deferred<{ branding: { enabled: boolean } }> | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/account-profile/client', () => ({
|
||||
@@ -39,24 +36,12 @@ vi.mock('@/features/account-profile/client', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/client')>()
|
||||
return {
|
||||
...actual,
|
||||
consoleQuery: {
|
||||
...actual.consoleQuery,
|
||||
systemFeatures: {
|
||||
get: {
|
||||
...actual.consoleQuery.systemFeatures.get,
|
||||
queryOptions: () => ({
|
||||
queryKey: systemFeaturesQueryKey,
|
||||
queryFn: () => mocks.systemFeaturesQuery!.promise,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock('@/features/system-features/client', () => ({
|
||||
systemFeaturesQueryOptions: () => ({
|
||||
queryKey: systemFeaturesQueryKey,
|
||||
queryFn: () => mocks.systemFeaturesQuery!.promise,
|
||||
}),
|
||||
}))
|
||||
|
||||
function createQueryClient() {
|
||||
return new QueryClient({
|
||||
@@ -93,7 +78,7 @@ describe('ConsoleBootstrapGate', () => {
|
||||
expect(screen.queryByText('Console shell')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
mocks.systemFeaturesQuery!.resolve(createSystemFeaturesFixture())
|
||||
mocks.systemFeaturesQuery!.resolve({ branding: { enabled: false } })
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Console shell')).toBeInTheDocument()
|
||||
@@ -102,9 +87,11 @@ describe('ConsoleBootstrapGate', () => {
|
||||
it('keeps atom consumers mounted when a cached profile background refetch fails', async () => {
|
||||
const queryClient = createQueryClient()
|
||||
queryClient.setQueryData(profileQueryKey, { id: 'user-1' }, { updatedAt: 1 })
|
||||
queryClient.setQueryData(systemFeaturesQueryKey, createSystemFeaturesFixture(), {
|
||||
updatedAt: 1,
|
||||
})
|
||||
queryClient.setQueryData(
|
||||
systemFeaturesQueryKey,
|
||||
{ branding: { enabled: false } },
|
||||
{ updatedAt: 1 },
|
||||
)
|
||||
|
||||
renderGate(<div>Console shell</div>, queryClient)
|
||||
|
||||
@@ -115,7 +102,7 @@ describe('ConsoleBootstrapGate', () => {
|
||||
|
||||
await act(async () => {
|
||||
mocks.profileQuery!.reject(new Error('profile refetch failed'))
|
||||
mocks.systemFeaturesQuery!.resolve(createSystemFeaturesFixture())
|
||||
mocks.systemFeaturesQuery!.resolve({ branding: { enabled: false } })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { DehydratedState } from '@tanstack/react-query'
|
||||
import type { ReactElement } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
rootQueryClient: undefined as QueryClient | undefined,
|
||||
queryClient: undefined as QueryClient | undefined,
|
||||
profileQueryFn: vi.fn(),
|
||||
systemFeaturesQueryFn: vi.fn(),
|
||||
workspaceQueryFn: vi.fn(),
|
||||
@@ -19,14 +18,9 @@ const mocks = vi.hoisted(() => ({
|
||||
basePath: '',
|
||||
}))
|
||||
|
||||
vi.mock('@/context/query-client-server', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/query-client-server')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getQueryClientServer: () => mocks.rootQueryClient,
|
||||
}
|
||||
})
|
||||
vi.mock('@/context/query-client-server', () => ({
|
||||
getQueryClientServer: () => mocks.queryClient,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/headers', () => ({
|
||||
headers: () => mocks.headers(),
|
||||
@@ -50,14 +44,6 @@ vi.mock('@/features/account-profile/server', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/system-features/server', () => ({
|
||||
serverSystemFeaturesQueryOptions: () => ({
|
||||
queryKey: ['console', 'system-features'],
|
||||
queryFn: mocks.systemFeaturesQueryFn,
|
||||
retry: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/server', () => ({
|
||||
getServerConsoleClientContext: () => mocks.getServerConsoleClientContext(),
|
||||
resolveServerConsoleApiUrl: (...args: unknown[]) => mocks.resolveServerConsoleApiUrl(...args),
|
||||
@@ -72,11 +58,19 @@ vi.mock('@/service/server', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/features/system-features/server', () => ({
|
||||
serverSystemFeaturesQueryOptions: () => ({
|
||||
queryKey: ['console', 'system-features'],
|
||||
queryFn: mocks.systemFeaturesQueryFn,
|
||||
retry: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('CommonLayoutHydrationBoundary', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.basePath = ''
|
||||
mocks.rootQueryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
mocks.queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
mocks.headers.mockResolvedValue(
|
||||
new Headers({
|
||||
'x-dify-pathname': '/apps',
|
||||
@@ -113,7 +107,7 @@ describe('CommonLayoutHydrationBoundary', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should prefetch common layout queries without requesting System Features', async () => {
|
||||
it('should prefetch common layout queries and render children', async () => {
|
||||
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
|
||||
|
||||
const element = await CommonLayoutHydrationBoundary({
|
||||
@@ -127,7 +121,7 @@ describe('CommonLayoutHydrationBoundary', () => {
|
||||
)
|
||||
expect(screen.getByText('Common shell')).toBeInTheDocument()
|
||||
expect(mocks.profileQueryFn).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.systemFeaturesQueryFn).not.toHaveBeenCalled()
|
||||
expect(mocks.systemFeaturesQueryFn).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.getServerConsoleClientContext).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.workspaceQueryOptions).toHaveBeenCalledWith({
|
||||
context: {
|
||||
@@ -139,25 +133,6 @@ describe('CommonLayoutHydrationBoundary', () => {
|
||||
expect(mocks.workspaceQueryFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should dehydrate only Common-owned queries', async () => {
|
||||
mocks.rootQueryClient?.setQueryData(['console', 'system-features'], {
|
||||
deployment_edition: 'CLOUD',
|
||||
})
|
||||
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
|
||||
|
||||
const element = await CommonLayoutHydrationBoundary({ children: null })
|
||||
const state = (element as ReactElement<{ state: DehydratedState }>).props.state
|
||||
const queryKeys = state.queries.map((query) => query.queryKey)
|
||||
|
||||
expect(queryKeys).toHaveLength(2)
|
||||
expect(queryKeys).toEqual(
|
||||
expect.arrayContaining([
|
||||
['common', 'user-profile'],
|
||||
['console', 'workspaces', 'current', 'post'],
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('should redirect unauthorized users to the refresh route with the current path', async () => {
|
||||
mocks.basePath = '/workflow'
|
||||
mocks.profileQueryFn.mockRejectedValue(
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render } from '@/test/console/render'
|
||||
import { AgentsAccessGuard } from '../agents-access-guard'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockConsoleStateReader = vi.fn()
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
|
||||
return createWorkspaceStateModuleMock(() => mockConsoleStateReader())
|
||||
})
|
||||
vi.mock('@/context/permission-state', async () => {
|
||||
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
|
||||
return createPermissionStateModuleMock(() => mockConsoleStateReader())
|
||||
})
|
||||
|
||||
type ConsoleStateFixture = {
|
||||
isLoadingCurrentWorkspace: boolean
|
||||
isLoadingWorkspacePermissionKeys: boolean
|
||||
workspacePermissionKeys: string[]
|
||||
currentWorkspace: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
const baseContext: ConsoleStateFixture = {
|
||||
isLoadingCurrentWorkspace: false,
|
||||
isLoadingWorkspacePermissionKeys: false,
|
||||
workspacePermissionKeys: ['agent.manage'],
|
||||
currentWorkspace: {
|
||||
id: 'workspace-1',
|
||||
},
|
||||
}
|
||||
|
||||
const setConsoleState = (overrides: Partial<ConsoleStateFixture> = {}) => {
|
||||
mockConsoleStateReader.mockReturnValue({
|
||||
...baseContext,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
describe('AgentsAccessGuard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setConsoleState()
|
||||
})
|
||||
|
||||
it('renders loading while the workspace is loading', () => {
|
||||
setConsoleState({ isLoadingCurrentWorkspace: true, currentWorkspace: { id: '' } })
|
||||
|
||||
render(
|
||||
<AgentsAccessGuard>
|
||||
<div>agents</div>
|
||||
</AgentsAccessGuard>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(screen.queryByText('agents')).not.toBeInTheDocument()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders loading while workspace permission keys are loading', () => {
|
||||
setConsoleState({ isLoadingWorkspacePermissionKeys: true, workspacePermissionKeys: [] })
|
||||
|
||||
render(
|
||||
<AgentsAccessGuard>
|
||||
<div>agents</div>
|
||||
</AgentsAccessGuard>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(screen.queryByText('agents')).not.toBeInTheDocument()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redirects to /apps without agent.manage', async () => {
|
||||
setConsoleState({ workspacePermissionKeys: ['dataset.create_and_management'] })
|
||||
|
||||
render(
|
||||
<AgentsAccessGuard>
|
||||
<div>agents</div>
|
||||
</AgentsAccessGuard>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('agents')).not.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders children with agent.manage', () => {
|
||||
render(
|
||||
<AgentsAccessGuard>
|
||||
<div>agents</div>
|
||||
</AgentsAccessGuard>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('agents')).toBeInTheDocument()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -9,12 +8,6 @@ vi.mock('../feature-guard', () => ({
|
||||
guardAgentV2Route: () => mocks.guardAgentV2Route(),
|
||||
}))
|
||||
|
||||
// Access control is covered by agents-access-guard.spec.tsx; this suite is
|
||||
// about the feature-flag guard only.
|
||||
vi.mock('../agents-access-guard', () => ({
|
||||
AgentsAccessGuard: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
describe('RosterLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useEffect } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { workspacePermissionKeysLoadingAtom } from '@/context/permission-state'
|
||||
import { currentWorkspaceIdAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state'
|
||||
import { useCanManageAgents } from '@/features/agent-v2/permissions'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
|
||||
export function AgentsAccessGuard({ children }: { children: ReactNode }) {
|
||||
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom)
|
||||
const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom)
|
||||
const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom)
|
||||
const canManageAgents = useCanManageAgents()
|
||||
const router = useRouter()
|
||||
const isLoadingAccess = isLoadingCurrentWorkspace || !!isLoadingWorkspacePermissionKeys
|
||||
const shouldRedirect = !isLoadingAccess && !!currentWorkspaceId && !canManageAgents
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRedirect) router.replace('/')
|
||||
}, [shouldRedirect, router])
|
||||
|
||||
if (isLoadingAccess || !currentWorkspaceId) return <Loading type="app" />
|
||||
|
||||
if (shouldRedirect) return null
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { AgentsAccessGuard } from './agents-access-guard'
|
||||
import { guardAgentV2Route } from './feature-guard'
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
guardAgentV2Route()
|
||||
|
||||
return <AgentsAccessGuard>{children}</AgentsAccessGuard>
|
||||
return children
|
||||
}
|
||||
|
||||
+2
-5
@@ -1,6 +1,5 @@
|
||||
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { renderWithAccountProfile as render } from '@/test/console/account-profile'
|
||||
import { AppACLPermission } from '@/utils/permission'
|
||||
import ChartView from '../chart-view'
|
||||
|
||||
@@ -14,7 +13,6 @@ const testState = vi.hoisted(() => ({
|
||||
currentUserId: 'user-1',
|
||||
workspacePermissionKeys: [] as string[],
|
||||
chartRenderSpy: vi.fn(),
|
||||
conversationPeriodSpy: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
@@ -48,9 +46,8 @@ vi.mock('@/app/components/app/overview/app-chart', () => ({
|
||||
testState.chartRenderSpy('avg-user-interactions')
|
||||
return <div>avg user interactions chart</div>
|
||||
},
|
||||
ConversationsChart: ({ period }: { period: PeriodParams }) => {
|
||||
ConversationsChart: () => {
|
||||
testState.chartRenderSpy('conversations')
|
||||
testState.conversationPeriodSpy(period)
|
||||
return <div>conversations chart</div>
|
||||
},
|
||||
CostChart: () => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client'
|
||||
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
|
||||
import { useAtomValue } from 'jotai'
|
||||
@@ -25,10 +23,10 @@ import {
|
||||
WorkflowMessagesChart,
|
||||
} from '@/app/components/app/overview/app-chart'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { userProfileIdAtom } from '@/context/account-state'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { getAppACLCapabilities } from '@/utils/permission'
|
||||
import LongTimeRangePicker from './long-time-range-picker'
|
||||
import TimeRangePicker from './time-range-picker'
|
||||
@@ -53,28 +51,7 @@ type IChartViewProps = {
|
||||
}
|
||||
|
||||
export default function ChartView({ appId, headerRight }: IChartViewProps) {
|
||||
const { data: deploymentEdition } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
|
||||
return (
|
||||
<ChartViewContent
|
||||
appId={appId}
|
||||
headerRight={headerRight}
|
||||
deploymentEdition={deploymentEdition}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ChartViewContent({
|
||||
appId,
|
||||
headerRight,
|
||||
deploymentEdition,
|
||||
}: IChartViewProps & { deploymentEdition: DeploymentEdition }) {
|
||||
const { t } = useTranslation()
|
||||
const isCloudEdition = deploymentEdition === 'CLOUD'
|
||||
const isNonCloudEdition = deploymentEdition === 'COMMUNITY' || deploymentEdition === 'ENTERPRISE'
|
||||
const docLink = useDocLink()
|
||||
const appDetail = useAppStore((state) => state.appDetail)
|
||||
const currentUserId = useAtomValue(userProfileIdAtom)
|
||||
@@ -90,8 +67,8 @@ function ChartViewContent({
|
||||
)
|
||||
const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow'
|
||||
const isWorkflow = appDetail?.mode === 'workflow'
|
||||
const [period, setPeriod] = useState<PeriodParams>(() =>
|
||||
isCloudEdition
|
||||
const [period, setPeriod] = useState<PeriodParams>(
|
||||
IS_CLOUD_EDITION
|
||||
? {
|
||||
name: t(($) => $['filter.period.today'], { ns: 'appLog' }),
|
||||
query: {
|
||||
@@ -135,14 +112,13 @@ function ChartViewContent({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex h-10 items-center justify-between pr-10 pl-6">
|
||||
{isCloudEdition && (
|
||||
{IS_CLOUD_EDITION ? (
|
||||
<TimeRangePicker
|
||||
ranges={TIME_PERIOD_MAPPING}
|
||||
onSelect={setPeriod}
|
||||
queryDateFormat={queryDateFormat}
|
||||
/>
|
||||
)}
|
||||
{isNonCloudEdition && (
|
||||
) : (
|
||||
<LongTimeRangePicker
|
||||
periodMapping={LONG_TIME_PERIOD_MAPPING}
|
||||
onSelect={setPeriod}
|
||||
|
||||
+8
@@ -43,6 +43,14 @@ vi.mock('@/context/permission-state', async () => {
|
||||
workspacePermissionKeys: [],
|
||||
}))
|
||||
})
|
||||
vi.mock('@/context/system-features-state', async () => {
|
||||
const { createSystemFeaturesStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
|
||||
return createSystemFeaturesStateModuleMock(() => ({
|
||||
datasetRbacEnabled: mockIsRbacEnabled,
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: undefined,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import type { FC } from 'react'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
@@ -14,8 +13,8 @@ import {
|
||||
workspacePermissionKeysAtom,
|
||||
workspacePermissionKeysLoadingAtom,
|
||||
} from '@/context/permission-state'
|
||||
import { datasetRbacEnabledAtom } from '@/context/system-features-state'
|
||||
import { currentWorkspaceLoadingAtom } from '@/context/workspace-state'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { useDatasetDetail } from '@/service/knowledge/use-dataset'
|
||||
@@ -61,10 +60,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const pathname = usePathname()
|
||||
const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom)
|
||||
const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom)
|
||||
const { data: isRbacEnabled } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ rbac_enabled }) => rbac_enabled,
|
||||
})
|
||||
const isRbacEnabled = useAtomValue(datasetRbacEnabledAtom)
|
||||
const currentUserId = useAtomValue(userProfileIdAtom)
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useQueryErrorResetBoundary } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FullScreenLoading } from '@/app/components/full-screen-loading'
|
||||
import { isLegacyBase401 } from '@/features/account-profile/client'
|
||||
@@ -13,7 +12,6 @@ type Props = Readonly<{
|
||||
|
||||
export default function CommonLayoutError({ error, unstable_retry }: Props) {
|
||||
const { t } = useTranslation('common')
|
||||
const { reset } = useQueryErrorResetBoundary()
|
||||
|
||||
console.error(error)
|
||||
|
||||
@@ -28,14 +26,7 @@ export default function CommonLayoutError({ error, unstable_retry }: Props) {
|
||||
<div className="system-sm-regular text-text-tertiary">
|
||||
{t(($) => $['errorBoundary.message'])}
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
reset()
|
||||
unstable_retry()
|
||||
}}
|
||||
>
|
||||
<Button size="small" variant="secondary" onClick={() => unstable_retry()}>
|
||||
{t(($) => $['errorBoundary.tryAgain'])}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import { makeQueryClient } from '@/context/query-client-server'
|
||||
import { getQueryClientServer } from '@/context/query-client-server'
|
||||
import { serverUserProfileQueryOptions } from '@/features/account-profile/server'
|
||||
import { serverSystemFeaturesQueryOptions } from '@/features/system-features/server'
|
||||
import { headers } from '@/next/headers'
|
||||
import { redirect } from '@/next/navigation'
|
||||
import {
|
||||
@@ -55,7 +56,7 @@ const handleProfileError = async (error: unknown) => {
|
||||
}
|
||||
|
||||
export async function CommonLayoutHydrationBoundary({ children }: { children: ReactNode }) {
|
||||
const queryClient = makeQueryClient()
|
||||
const queryClient = getQueryClientServer()
|
||||
const accountProfileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH)
|
||||
|
||||
if (accountProfileUrl) {
|
||||
@@ -64,6 +65,7 @@ export async function CommonLayoutHydrationBoundary({ children }: { children: Re
|
||||
|
||||
await Promise.all([
|
||||
queryClient.fetchQuery(serverUserProfileQueryOptions()),
|
||||
queryClient.prefetchQuery(serverSystemFeaturesQueryOptions()),
|
||||
queryClient.prefetchQuery(
|
||||
serverConsoleQuery.workspaces.current.post.queryOptions({
|
||||
context,
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { LicenseStatus } from '@/features/system-features/constants'
|
||||
import Link from '@/next/link'
|
||||
@@ -18,9 +19,6 @@ const NormalForm = () => {
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const isNonCloudEdition =
|
||||
systemFeatures.deployment_edition === 'COMMUNITY' ||
|
||||
systemFeatures.deployment_edition === 'ENTERPRISE'
|
||||
const [authType, updateAuthType] = useState<'code' | 'password'>('password')
|
||||
const [showORLine, setShowORLine] = useState(false)
|
||||
const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false)
|
||||
@@ -238,7 +236,7 @@ const NormalForm = () => {
|
||||
{t(($) => $.pp, { ns: 'login' })}
|
||||
</Link>
|
||||
</div>
|
||||
{isNonCloudEdition && (
|
||||
{IS_CE_EDITION && (
|
||||
<div className="w-hull mt-2 block system-xs-regular text-text-tertiary">
|
||||
{t(($) => $.goToInit, { ns: 'login' })}
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { QueryErrorResetBoundary } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import CommonLayoutError from '@/app/(commonLayout)/error'
|
||||
import AppError from '@/app/error'
|
||||
|
||||
describe('route error recovery', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'root error',
|
||||
renderError: (retry: () => void) => <AppError error={new Error('failed')} reset={retry} />,
|
||||
},
|
||||
{
|
||||
name: 'common layout error',
|
||||
renderError: (retry: () => void) => (
|
||||
<CommonLayoutError error={new Error('failed')} unstable_retry={retry} />
|
||||
),
|
||||
},
|
||||
])('resets failed queries before retrying the $name', async ({ renderError }) => {
|
||||
const user = userEvent.setup()
|
||||
const retry = vi.fn()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
render(
|
||||
<QueryErrorResetBoundary>
|
||||
{({ isReset }) => renderError(() => retry(isReset()))}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.errorBoundary.tryAgain' }))
|
||||
|
||||
expect(retry).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
@@ -1,69 +0,0 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
let queryClient: QueryClient
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getSystemFeatures: vi.fn(),
|
||||
getCloudAnalyticsBoundaryState: vi.fn(() => ({ enabled: false })),
|
||||
requestHeaders: new Headers(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/query-client-server', () => ({
|
||||
getQueryClientServer: () => queryClient,
|
||||
}))
|
||||
|
||||
vi.mock('@/env', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/env')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getDatasetMap: () => ({}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/system-features/server', () => ({
|
||||
serverSystemFeaturesQueryOptions: () => ({
|
||||
queryKey: ['console', 'system-features'],
|
||||
queryFn: mocks.getSystemFeatures,
|
||||
retry: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/server', () => ({
|
||||
getLocaleOnServer: async () => 'en-US',
|
||||
}))
|
||||
|
||||
vi.mock('@/next/headers', () => ({
|
||||
headers: async () => mocks.requestHeaders,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/analytics-consent/cloud-analytics-state', () => ({
|
||||
getCloudAnalyticsBoundaryState: mocks.getCloudAnalyticsBoundaryState,
|
||||
}))
|
||||
|
||||
describe('Root layout System Features bootstrap', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
})
|
||||
|
||||
it('renders with the resolved deployment edition', async () => {
|
||||
mocks.getSystemFeatures.mockResolvedValue({ deployment_edition: 'CLOUD' })
|
||||
const { default: RootLayout } = await import('../layout')
|
||||
|
||||
await expect(RootLayout({ children: <div>App</div> })).resolves.toBeDefined()
|
||||
|
||||
expect(mocks.getSystemFeatures).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.getCloudAnalyticsBoundaryState).toHaveBeenCalledWith(mocks.requestHeaders, 'CLOUD')
|
||||
})
|
||||
|
||||
it('propagates System Features failures without rendering a fallback', async () => {
|
||||
const error = new Error('system features unavailable')
|
||||
mocks.getSystemFeatures.mockRejectedValue(error)
|
||||
const { default: RootLayout } = await import('../layout')
|
||||
|
||||
await expect(RootLayout({ children: <div>App</div> })).rejects.toBe(error)
|
||||
|
||||
expect(mocks.getCloudAnalyticsBoundaryState).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,7 @@ import AppIcon from '@/app/components/base/app-icon'
|
||||
import Input from '@/app/components/base/input'
|
||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import Collapse from '@/app/components/header/account-setting/collapse'
|
||||
import { validPassword } from '@/config'
|
||||
import { IS_CE_EDITION, validPassword } from '@/config'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
@@ -243,7 +243,7 @@ export default function AccountPage() {
|
||||
wrapperClassName="mt-2"
|
||||
/>
|
||||
)}
|
||||
{systemFeatures.deployment_edition === 'CLOUD' && (
|
||||
{!IS_CE_EDITION && (
|
||||
<Button
|
||||
className="mt-2 text-components-button-destructive-secondary-text"
|
||||
onClick={() => setShowDeleteAccountModal(true)}
|
||||
|
||||
@@ -4,12 +4,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
basePath: '',
|
||||
isCloudEdition: false,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
API_PREFIX: 'http://localhost:5001/console/api',
|
||||
CSRF_COOKIE_NAME: () => 'csrf_token',
|
||||
CSRF_HEADER_NAME: 'X-CSRF-Token',
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mocks.isCloudEdition
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('server-only', () => ({}))
|
||||
@@ -48,6 +52,7 @@ describe('auth refresh route', () => {
|
||||
vi.resetModules()
|
||||
vi.unstubAllGlobals()
|
||||
mocks.basePath = ''
|
||||
mocks.isCloudEdition = false
|
||||
})
|
||||
|
||||
it('should refresh cookies and redirect back to the requested path', async () => {
|
||||
@@ -239,7 +244,8 @@ describe('auth refresh route', () => {
|
||||
expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F')
|
||||
})
|
||||
|
||||
it('should keep a staging fallback on the current deployment after refresh', async () => {
|
||||
it('should keep a Cloud staging fallback on the current deployment after refresh', async () => {
|
||||
mocks.isCloudEdition = true
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
|
||||
const { GET } = await import('../route')
|
||||
|
||||
@@ -254,7 +260,8 @@ describe('auth refresh route', () => {
|
||||
expect(response.headers.get('location')).toBe('/')
|
||||
})
|
||||
|
||||
it('should carry the current deployment fallback through signin when refresh fails', async () => {
|
||||
it('should carry the current Cloud deployment fallback through signin when refresh fails', async () => {
|
||||
mocks.isCloudEdition = true
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
|
||||
const { GET } = await import('../route')
|
||||
|
||||
@@ -270,6 +277,7 @@ describe('auth refresh route', () => {
|
||||
})
|
||||
|
||||
it('should use the current deployment home when a trusted target loops back to auth refresh', async () => {
|
||||
mocks.isCloudEdition = true
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
|
||||
const { GET } = await import('../route')
|
||||
|
||||
|
||||
@@ -6,10 +6,17 @@ import { setAnalyticsConsent } from '@/app/components/base/analytics-consent/con
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import ExternalAttributionRecorder from '../external-attribution-recorder'
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({ IS_CLOUD_EDITION: true }))
|
||||
const { mockRememberCreateAppExternalAttribution } = vi.hoisted(() => ({
|
||||
mockRememberCreateAppExternalAttribution: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.IS_CLOUD_EDITION
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
@@ -36,6 +43,7 @@ describe('ExternalAttributionRecorder', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Cookies.remove('utm_info')
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
setAnalyticsConsent('granted')
|
||||
setSearchParams()
|
||||
})
|
||||
@@ -142,4 +150,14 @@ describe('ExternalAttributionRecorder', () => {
|
||||
})
|
||||
expect(mockRememberCreateAppExternalAttribution).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('is a no-op outside the cloud edition', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = false
|
||||
setSearchParams('utm_source=dify_blog&slug=get-started-with-dify')
|
||||
|
||||
render(<ExternalAttributionRecorder />)
|
||||
|
||||
expect(getUtmInfoCookie()).toBeNull()
|
||||
expect(mockRememberCreateAppExternalAttribution).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,15 +35,7 @@ vi.mock('@/service/access-control', () => ({
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
systemFeatures: {
|
||||
get: {
|
||||
queryKey: () => ['system-features'],
|
||||
queryOptions: (options: Record<string, unknown> = {}) => ({
|
||||
queryKey: ['system-features'],
|
||||
...options,
|
||||
}),
|
||||
},
|
||||
},
|
||||
systemFeatures: { get: { queryKey: () => ['system-features'] } },
|
||||
enterprise: {
|
||||
webAppAuth: {
|
||||
updateWebAppWhitelistSubjects: {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import {
|
||||
AccessModeDisplay,
|
||||
|
||||
@@ -3,11 +3,10 @@ import type { IConfigVarProps } from '../index'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import DebugConfigurationContext from '@/context/debug-configuration'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import ConfigVar, { ADD_EXTERNAL_DATA_TOOL } from '../index'
|
||||
|
||||
|
||||
+1
-2
@@ -1,11 +1,10 @@
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useStore } from '@/app/components/app/store'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import ConfigModal from '../index'
|
||||
|
||||
|
||||
+2
-2
@@ -8,6 +8,7 @@ import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { defaultSystemFeatures } from '@/features/system-features/config'
|
||||
import {
|
||||
ChunkingMode,
|
||||
DatasetPermission,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
import { updateDatasetSetting } from '@/service/datasets'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { createSystemFeaturesFixture } from '@/test/console/system-features'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { DatasetACLPermission } from '@/utils/permission'
|
||||
import SettingsModal from '../index'
|
||||
@@ -210,7 +210,7 @@ const renderWithProviders = (dataset: DataSet) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
queryClient.setQueryData(systemFeaturesQueryOptions().queryKey, createSystemFeaturesFixture())
|
||||
queryClient.setQueryData(systemFeaturesQueryOptions().queryKey, defaultSystemFeatures)
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { App } from '@/models/explore'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import AppListContext from '@/context/app-list-context'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppCard from '../index'
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn() }))
|
||||
|
||||
const render = (ui: ReactElement) =>
|
||||
renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' } })
|
||||
const mockConfig = vi.hoisted(() => ({ isCloudEdition: true }))
|
||||
vi.mock('@/config', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/config')>()),
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.isCloudEdition
|
||||
},
|
||||
}))
|
||||
|
||||
const app: App = {
|
||||
can_trial: true,
|
||||
@@ -43,6 +47,7 @@ const app: App = {
|
||||
|
||||
describe('AppCard', () => {
|
||||
beforeEach(() => {
|
||||
mockConfig.isCloudEdition = true
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
|
||||
@@ -4,14 +4,13 @@ import { PlusIcon } from '@heroicons/react/20/solid'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiInformation2Line } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContextSelector } from 'use-context-selector'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import AppListContext from '@/context/app-list-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { AppTypeIcon, AppTypeLabel } from '../../type-selector'
|
||||
|
||||
type AppCardProps = {
|
||||
@@ -22,12 +21,8 @@ type AppCardProps = {
|
||||
|
||||
const AppCard = ({ app, canCreate, onCreate }: AppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: deploymentEdition } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
const { app: appBasicInfo } = app
|
||||
const canViewApp = deploymentEdition === 'CLOUD'
|
||||
const canViewApp = IS_CLOUD_EDITION
|
||||
const setShowTryAppPanel = useContextSelector(AppListContext, (ctx) => ctx.setShowTryAppPanel)
|
||||
const handleShowTryAppPanel = useCallback(() => {
|
||||
trackEvent('preview_template', {
|
||||
|
||||
@@ -3,26 +3,27 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import InSiteMessageNotification from '../notification'
|
||||
|
||||
const { mockEdition, mockNotification, mockNotificationDismiss } = vi.hoisted(() => ({
|
||||
mockEdition: {
|
||||
value: 'CLOUD' as 'COMMUNITY' | 'ENTERPRISE' | 'CLOUD' | null,
|
||||
const { mockConfig, mockNotification, mockNotificationDismiss } = vi.hoisted(() => ({
|
||||
mockConfig: {
|
||||
isCloudEdition: true,
|
||||
},
|
||||
mockNotification: vi.fn(),
|
||||
mockNotificationDismiss: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock(import('@/config'), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.isCloudEdition
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
systemFeatures: {
|
||||
get: {
|
||||
queryKey: () => ['console', 'systemFeatures', 'get'],
|
||||
queryOptions: (options?: Record<string, unknown>) => ({
|
||||
queryKey: ['console', 'systemFeatures', 'get'],
|
||||
queryFn: () => new Promise(() => {}),
|
||||
...options,
|
||||
}),
|
||||
},
|
||||
},
|
||||
notification: {
|
||||
get: {
|
||||
queryOptions: (options?: Record<string, unknown>) => ({
|
||||
@@ -55,9 +56,6 @@ const createWrapper = () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
queryClient.setQueryData(['console', 'systemFeatures', 'get'], {
|
||||
deployment_edition: mockEdition.value,
|
||||
})
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
@@ -69,7 +67,7 @@ const createWrapper = () => {
|
||||
describe('InSiteMessageNotification', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEdition.value = 'CLOUD'
|
||||
mockConfig.isCloudEdition = true
|
||||
vi.stubGlobal('open', vi.fn())
|
||||
})
|
||||
|
||||
@@ -80,7 +78,7 @@ describe('InSiteMessageNotification', () => {
|
||||
// Validate query gating and empty state rendering.
|
||||
describe('Rendering', () => {
|
||||
it('should render null and skip query when not cloud edition', async () => {
|
||||
mockEdition.value = 'COMMUNITY'
|
||||
mockConfig.isCloudEdition = false
|
||||
const Wrapper = createWrapper()
|
||||
const { container } = render(<InSiteMessageNotification />, { wrapper: Wrapper })
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import type { InSiteMessageActionItem } from './index'
|
||||
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import InSiteMessage from './index'
|
||||
|
||||
@@ -56,25 +56,20 @@ function parseNotificationBody(body: string): NotificationBodyPayload | null {
|
||||
|
||||
function InSiteMessageNotification() {
|
||||
const { t } = useTranslation()
|
||||
const { data: deploymentEdition } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
const isCloudEdition = deploymentEdition === 'CLOUD'
|
||||
const dismissNotificationMutation = useMutation(
|
||||
consoleQuery.notification.dismiss.post.mutationOptions(),
|
||||
)
|
||||
|
||||
const { data } = useQuery(
|
||||
consoleQuery.notification.get.queryOptions({
|
||||
enabled: isCloudEdition,
|
||||
enabled: IS_CLOUD_EDITION,
|
||||
}),
|
||||
)
|
||||
|
||||
const notification = data?.notifications?.[0]
|
||||
const parsedBody = notification ? parseNotificationBody(notification.body) : null
|
||||
|
||||
if (!isCloudEdition || !notification || !notification.notification_id) return null
|
||||
if (!IS_CLOUD_EDITION || !notification || !notification.notification_id) return null
|
||||
|
||||
const notificationId = notification.notification_id
|
||||
const fallbackActions: InSiteMessageActionItem[] = [
|
||||
|
||||
@@ -5,10 +5,17 @@ import { Plan } from '@/app/components/billing/type'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import { ArchivedLogsNotice } from '../archived-logs-notice'
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
...actual,
|
||||
IS_CLOUD_EDITION: true,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
|
||||
@@ -50,12 +57,6 @@ function mockProviderPlan(planType: Plan) {
|
||||
|
||||
describe('ArchivedLogsNotice', () => {
|
||||
const setShowAccountSettingModal = vi.fn()
|
||||
const renderNotice = () => {
|
||||
const { wrapper } = createConsoleQueryWrapper({
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
})
|
||||
return render(<ArchivedLogsNotice />, { wrapper })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -68,7 +69,7 @@ describe('ArchivedLogsNotice', () => {
|
||||
})
|
||||
|
||||
it('should show notice for paid workspace managers', () => {
|
||||
renderNotice()
|
||||
render(<ArchivedLogsNotice />)
|
||||
|
||||
expect(screen.getByText('appLog.archives.notice.description')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'appLog.archives.notice.action' }))
|
||||
@@ -80,7 +81,7 @@ describe('ArchivedLogsNotice', () => {
|
||||
it('should not show notice for sandbox workspaces', () => {
|
||||
mockProviderPlan(Plan.sandbox)
|
||||
|
||||
renderNotice()
|
||||
render(<ArchivedLogsNotice />)
|
||||
|
||||
expect(screen.queryByText('appLog.archives.notice.description')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
|
||||
export function ArchivedLogsNotice() {
|
||||
const { t } = useTranslation()
|
||||
const { data: deploymentEdition } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
|
||||
const { enableBilling, plan } = useProviderContext()
|
||||
const setShowAccountSettingModal = useModalContextSelector(
|
||||
@@ -23,7 +18,7 @@ export function ArchivedLogsNotice() {
|
||||
)
|
||||
|
||||
if (
|
||||
deploymentEdition !== 'CLOUD' ||
|
||||
!IS_CLOUD_EDITION ||
|
||||
!isCurrentWorkspaceManager ||
|
||||
!enableBilling ||
|
||||
plan.type === Plan.sandbox
|
||||
|
||||
@@ -313,7 +313,6 @@ describe('app-card-utils', () => {
|
||||
expect(snippet).toContain('name: "Alice"')
|
||||
expect(snippet).toContain('count: "5"')
|
||||
expect(snippet).toContain('background-color: #FF0000')
|
||||
expect(snippet).toContain(`baseUrl: 'https://example.com${basePath}'`)
|
||||
})
|
||||
|
||||
it('should generate an embedded script snippet with empty inputs comment', () => {
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
interactions,
|
||||
mockUseModalContext,
|
||||
scenarios,
|
||||
setDeploymentEdition,
|
||||
} from './test-utils'
|
||||
|
||||
vi.mock('@/config', () => ({ IS_CE_EDITION: false }))
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('APIKeyInfoPanel - Cloud Edition', () => {
|
||||
@@ -15,7 +16,6 @@ describe('APIKeyInfoPanel - Cloud Edition', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllMocks()
|
||||
setDeploymentEdition('CLOUD')
|
||||
mockUseModalContext.mockReturnValue({
|
||||
...defaultModalContext,
|
||||
setShowAccountSettingModal,
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
interactions,
|
||||
mockUseModalContext,
|
||||
scenarios,
|
||||
setDeploymentEdition,
|
||||
textKeys,
|
||||
} from './test-utils'
|
||||
|
||||
vi.mock('@/config', () => ({ IS_CE_EDITION: true }))
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('APIKeyInfoPanel - Community Edition', () => {
|
||||
@@ -16,7 +17,6 @@ describe('APIKeyInfoPanel - Community Edition', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllMocks()
|
||||
setDeploymentEdition('COMMUNITY')
|
||||
mockUseModalContext.mockReturnValue({
|
||||
...defaultModalContext,
|
||||
setShowAccountSettingModal,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import type { RenderOptions } from '@testing-library/react'
|
||||
import type { Mock, MockedFunction } from 'vitest'
|
||||
import type { ModalContextState } from '@/context/modal-context'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import {
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
useModalContextSelector as actualUseModalContextSelector,
|
||||
} from '@/context/modal-context'
|
||||
import { useProviderContext as actualUseProviderContext } from '@/context/provider-context'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import APIKeyInfoPanel from '../index'
|
||||
|
||||
const { mockRouterPush } = vi.hoisted(() => ({
|
||||
@@ -104,7 +102,6 @@ type APIKeyInfoPanelRenderOptions = {
|
||||
} & Omit<RenderOptions, 'wrapper'>
|
||||
|
||||
const mainButtonName = /appOverview\.apiKeyInfo\.setAPIBtn/
|
||||
let deploymentEdition: DeploymentEdition = 'COMMUNITY'
|
||||
|
||||
// Setup function to configure mocks
|
||||
function setupMocks(overrides: MockOverrides = {}) {
|
||||
@@ -132,10 +129,7 @@ function renderAPIKeyInfoPanel(options: APIKeyInfoPanelRenderOptions = {}) {
|
||||
|
||||
setupMocks(mockOverrides)
|
||||
|
||||
return renderWithConsoleQuery(<APIKeyInfoPanel />, {
|
||||
...renderOptions,
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
})
|
||||
return render(<APIKeyInfoPanel />, renderOptions)
|
||||
}
|
||||
|
||||
// Helper functions for common test scenarios
|
||||
@@ -206,9 +200,5 @@ export function clearAllMocks() {
|
||||
vi.clearAllMocks()
|
||||
}
|
||||
|
||||
export function setDeploymentEdition(value: DeploymentEdition) {
|
||||
deploymentEdition = value
|
||||
}
|
||||
|
||||
// Export mock functions for external access
|
||||
export { defaultModalContext, mockUseModalContext }
|
||||
|
||||
@@ -3,22 +3,17 @@ import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
|
||||
const APIKeyInfoPanel: FC = () => {
|
||||
const { data: deploymentEdition } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
const isCloud = deploymentEdition === 'CLOUD'
|
||||
const isCloud = !IS_CE_EDITION
|
||||
|
||||
const { isAPIKeySet } = useProviderContext()
|
||||
const openIntegrationsSetting = useIntegrationsSetting()
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
@@ -176,16 +177,20 @@ export const getEmbeddedScriptSnippet = ({
|
||||
primaryColor: string
|
||||
isTestEnv?: boolean
|
||||
inputValues: Record<string, WorkflowLaunchInputValue>
|
||||
}) => {
|
||||
return `<script>
|
||||
}) =>
|
||||
`<script>
|
||||
window.difyChatbotConfig = {
|
||||
token: '${token}'${
|
||||
isTestEnv
|
||||
? `,
|
||||
isDev: true`
|
||||
: ''
|
||||
},
|
||||
baseUrl: '${url}${basePath}'${
|
||||
}${
|
||||
IS_CE_EDITION
|
||||
? `,
|
||||
baseUrl: '${url}${basePath}'`
|
||||
: ''
|
||||
}${
|
||||
webAppRoute !== 'chatbot'
|
||||
? `,
|
||||
routeSegment: '${webAppRoute}'`
|
||||
@@ -216,7 +221,6 @@ export const getEmbeddedScriptSnippet = ({
|
||||
height: 40rem !important;
|
||||
}
|
||||
</style>`
|
||||
}
|
||||
|
||||
export const getChromePluginContent = (iframeUrl: string) => `ChatBot URL: ${iframeUrl}`
|
||||
|
||||
|
||||
@@ -105,10 +105,6 @@ vi.mock('@/service/client', () => ({
|
||||
systemFeatures: {
|
||||
get: {
|
||||
queryKey: () => ['console', 'systemFeatures', 'get'],
|
||||
queryOptions: (options: Record<string, unknown> = {}) => ({
|
||||
queryKey: ['console', 'systemFeatures', 'get'],
|
||||
...options,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
AMPLITUDE_API_KEY: 'test-api-key',
|
||||
IS_CLOUD_EDITION: true,
|
||||
}))
|
||||
const mockConsent = vi.hoisted(() => ({
|
||||
value: 'granted' as 'unknown' | 'denied' | 'granted',
|
||||
@@ -16,6 +17,12 @@ vi.mock('@/config', () => ({
|
||||
get AMPLITUDE_API_KEY() {
|
||||
return mockConfig.AMPLITUDE_API_KEY
|
||||
},
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.IS_CLOUD_EDITION
|
||||
},
|
||||
get isAmplitudeEnabled() {
|
||||
return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@amplitude/analytics-browser', () => ({
|
||||
@@ -37,6 +44,7 @@ describe('AmplitudeProvider', () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
mockConsent.value = 'granted'
|
||||
;({ AmplitudeProvider } = await import('../AmplitudeProvider'))
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
AMPLITUDE_API_KEY: 'test-api-key',
|
||||
IS_CLOUD_EDITION: true,
|
||||
}))
|
||||
|
||||
let ensureAmplitudeInitialized: typeof import('../init').ensureAmplitudeInitialized
|
||||
@@ -12,6 +13,12 @@ vi.mock('@/config', () => ({
|
||||
get AMPLITUDE_API_KEY() {
|
||||
return mockConfig.AMPLITUDE_API_KEY
|
||||
},
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.IS_CLOUD_EDITION
|
||||
},
|
||||
get isAmplitudeEnabled() {
|
||||
return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@amplitude/analytics-browser', () => ({
|
||||
@@ -29,6 +36,7 @@ describe('amplitude init helper', () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
;({ ensureAmplitudeInitialized } = await import('../init'))
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { flushEvents, resetUser, setUserId, setUserProperties, trackEvent } from
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
consent: 'granted' as 'unknown' | 'denied' | 'granted',
|
||||
enabled: true,
|
||||
initialized: true,
|
||||
}))
|
||||
|
||||
@@ -23,6 +24,12 @@ const MockIdentify = vi.hoisted(
|
||||
},
|
||||
)
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get isAmplitudeEnabled() {
|
||||
return mockState.enabled
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/analytics-consent/consent-store', () => ({
|
||||
getAnalyticsConsent: () => mockState.consent,
|
||||
}))
|
||||
@@ -44,11 +51,12 @@ describe('amplitude utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockState.consent = 'granted'
|
||||
mockState.enabled = true
|
||||
mockState.initialized = true
|
||||
})
|
||||
|
||||
describe('trackEvent', () => {
|
||||
it('should call amplitude.track and return its result when the consented SDK is initialized', () => {
|
||||
it('should call amplitude.track and return its result when amplitude is enabled', () => {
|
||||
const trackResult = { promise: Promise.resolve({}) }
|
||||
mockTrack.mockReturnValue(trackResult)
|
||||
|
||||
@@ -59,8 +67,8 @@ describe('amplitude utils', () => {
|
||||
expect(result).toBe(trackResult)
|
||||
})
|
||||
|
||||
it('should not call amplitude.track before the SDK initializes', () => {
|
||||
mockState.initialized = false
|
||||
it('should not call amplitude.track when amplitude is disabled', () => {
|
||||
mockState.enabled = false
|
||||
|
||||
trackEvent('dataset_created', { source: 'wizard' })
|
||||
|
||||
@@ -85,7 +93,7 @@ describe('amplitude utils', () => {
|
||||
})
|
||||
|
||||
describe('flushEvents', () => {
|
||||
it('should call amplitude.flush and return its result when the consented SDK is initialized', () => {
|
||||
it('should call amplitude.flush and return its result when amplitude is enabled', () => {
|
||||
const flushResult = { promise: Promise.resolve() }
|
||||
mockFlush.mockReturnValue(flushResult)
|
||||
|
||||
@@ -95,8 +103,8 @@ describe('amplitude utils', () => {
|
||||
expect(result).toBe(flushResult)
|
||||
})
|
||||
|
||||
it('should not call amplitude.flush before the SDK initializes', () => {
|
||||
mockState.initialized = false
|
||||
it('should not call amplitude.flush when amplitude is disabled', () => {
|
||||
mockState.enabled = false
|
||||
|
||||
flushEvents()
|
||||
|
||||
@@ -113,15 +121,15 @@ describe('amplitude utils', () => {
|
||||
})
|
||||
|
||||
describe('setUserId', () => {
|
||||
it('should call amplitude.setUserId when the consented SDK is initialized', () => {
|
||||
it('should call amplitude.setUserId when amplitude is enabled', () => {
|
||||
setUserId('user-123')
|
||||
|
||||
expect(mockSetUserId).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetUserId).toHaveBeenCalledWith('user-123')
|
||||
})
|
||||
|
||||
it('should not call amplitude.setUserId before the SDK initializes', () => {
|
||||
mockState.initialized = false
|
||||
it('should not call amplitude.setUserId when amplitude is disabled', () => {
|
||||
mockState.enabled = false
|
||||
|
||||
setUserId('user-123')
|
||||
|
||||
@@ -138,7 +146,7 @@ describe('amplitude utils', () => {
|
||||
})
|
||||
|
||||
describe('setUserProperties', () => {
|
||||
it('should build an identify event when the consented SDK is initialized', () => {
|
||||
it('should build identify event and call amplitude.identify when amplitude is enabled', () => {
|
||||
const properties = {
|
||||
role: 'owner',
|
||||
seats: 3,
|
||||
@@ -157,8 +165,8 @@ describe('amplitude utils', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should not call amplitude.identify before the SDK initializes', () => {
|
||||
mockState.initialized = false
|
||||
it('should not call amplitude.identify when amplitude is disabled', () => {
|
||||
mockState.enabled = false
|
||||
|
||||
setUserProperties({ role: 'owner' })
|
||||
|
||||
@@ -175,14 +183,14 @@ describe('amplitude utils', () => {
|
||||
})
|
||||
|
||||
describe('resetUser', () => {
|
||||
it('should call amplitude.reset when the consented SDK is initialized', () => {
|
||||
it('should call amplitude.reset when amplitude is enabled', () => {
|
||||
resetUser()
|
||||
|
||||
expect(mockReset).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call amplitude.reset before the SDK initializes', () => {
|
||||
mockState.initialized = false
|
||||
it('should not call amplitude.reset when amplitude is disabled', () => {
|
||||
mockState.enabled = false
|
||||
|
||||
resetUser()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as amplitude from '@amplitude/analytics-browser'
|
||||
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||
import { AMPLITUDE_API_KEY } from '@/config'
|
||||
import { AMPLITUDE_API_KEY, isAmplitudeEnabled } from '@/config'
|
||||
|
||||
export type AmplitudeInitializationOptions = {
|
||||
sessionReplaySampleRate?: number
|
||||
@@ -61,7 +61,7 @@ const createPageNameEnrichmentPlugin = (): amplitude.Types.EnrichmentPlugin => {
|
||||
export const ensureAmplitudeInitialized = ({
|
||||
sessionReplaySampleRate = 0.5,
|
||||
}: AmplitudeInitializationOptions = {}) => {
|
||||
if (!AMPLITUDE_API_KEY || isAmplitudeInitialized) return
|
||||
if (!isAmplitudeEnabled || isAmplitudeInitialized) return
|
||||
|
||||
isAmplitudeInitialized = true
|
||||
|
||||
@@ -90,6 +90,6 @@ export const ensureAmplitudeInitialized = ({
|
||||
}
|
||||
|
||||
export const setAmplitudeOptOut = (optOut: boolean) => {
|
||||
if (!AMPLITUDE_API_KEY || !isAmplitudeInitialized) return
|
||||
if (!isAmplitudeEnabled || !isAmplitudeInitialized) return
|
||||
amplitude.setOptOut(optOut)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user