refactor: centralize deployment edition in system features (#39454)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: GareArc <garethcxy@dify.ai>
This commit is contained in:
yyh
2026-07-24 07:15:02 +00:00
committed by GitHub
co-authored by autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> GareArc
parent eac9f69ad1
commit 66a545fc6d
310 changed files with 3277 additions and 3047 deletions
+2 -1
View File
@@ -6,6 +6,7 @@ from sqlalchemy import delete, select, update
from sqlalchemy.orm import sessionmaker
from configs import dify_config
from enums.deployment_edition import DeploymentEdition
from events.app_event import app_was_created
from extensions.ext_database import db
from extensions.ext_redis import redis_client
@@ -42,7 +43,7 @@ def reset_encrypt_key_pair():
After the reset, all LLM credentials will become invalid, requiring re-entry.
Only support SELF_HOSTED mode.
"""
if dify_config.EDITION != "SELF_HOSTED":
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
click.echo(click.style("This command is only for SELF_HOSTED installations.", fg="red"))
return
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
+9
View File
@@ -5,6 +5,7 @@ from typing import Any, override
from pydantic.fields import FieldInfo
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource
from enums.deployment_edition import DeploymentEdition
from libs.file_utils import search_file_upwards
from .deploy import DeploymentConfig
@@ -116,3 +117,11 @@ class DifyConfig(
),
),
)
@property
def DEPLOYMENT_EDITION(self) -> DeploymentEdition:
if self.EDITION == "CLOUD":
return DeploymentEdition.CLOUD
if self.ENTERPRISE_ENABLED:
return DeploymentEdition.ENTERPRISE
return DeploymentEdition.COMMUNITY
+5
View File
@@ -1497,6 +1497,11 @@ 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,
+5 -4
View File
@@ -162,9 +162,10 @@ class LoginApi(Resource):
# SELF_HOSTED only have one workspace
tenants = TenantService.get_join_tenants(account, session=db.session())
if len(tenants) == 0:
system_features = FeatureService.get_system_features()
if system_features.is_allow_create_workspace and not system_features.license.workspaces.is_available():
if (
FeatureService.get_system_features().is_allow_create_workspace
and not FeatureService.get_license().workspaces.is_available()
):
raise WorkspacesLimitExceeded()
else:
return SimpleResultOptionalDataResponse(
@@ -310,7 +311,7 @@ class EmailCodeLoginApi(Resource):
if account:
tenants = TenantService.get_join_tenants(account, session=db.session())
if not tenants:
workspaces = FeatureService.get_system_features().license.workspaces
workspaces = FeatureService.get_license().workspaces
if not workspaces.is_available():
raise WorkspacesLimitExceeded()
if not FeatureService.get_system_features().is_allow_create_workspace:
+27 -5
View File
@@ -3,10 +3,11 @@ from flask_restx import Resource
from controllers.common.schema import register_response_schema_models
from fields.base import ResponseModel
from libs.helper import dump_response
from libs.login import current_account_with_tenant_optional, login_required
from libs.login import login_required
from services.feature_service import (
FeatureModel,
FeatureService,
LicenseModel,
LimitationModel,
SystemFeatureModel,
)
@@ -32,6 +33,7 @@ register_response_schema_models(
console_ns,
AppDslVersionResponse,
FeatureModel,
LicenseModel,
LimitationModel,
SystemFeatureModel,
TrialModelsResponse,
@@ -135,8 +137,28 @@ class SystemFeatureApi(Resource):
Authentication would create circular dependency (can't login without dashboard loading).
Only non-sensitive configuration data should be returned by this endpoint.
Only non-sensitive configuration data should be returned by this endpoint. Authenticated
license detail is served separately by SystemFeatureLicenseApi.
"""
current_user, _ = current_account_with_tenant_optional()
is_authenticated = current_user is not None
return FeatureService.get_system_features(is_authenticated=is_authenticated).model_dump()
return FeatureService.get_system_features().model_dump()
@console_ns.route("/system-features/license")
class SystemFeatureLicenseApi(Resource):
@console_ns.doc("get_system_license")
@console_ns.doc(description="Get license status and usage detail")
@console_ns.response(
200,
"Success",
console_ns.models[LicenseModel.__name__],
)
@setup_required
@login_required
@account_initialization_required
def get(self):
"""Get full license detail (status, expiry, workspace/seat usage).
Authenticated counterpart to the license *status* exposed on the public
system-features endpoint.
"""
return FeatureService.get_license().model_dump()
+2 -1
View File
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
from configs import dify_config
from controllers.fastopenapi import console_router
from enums.deployment_edition import DeploymentEdition
from extensions.ext_database import db
from models.model import DifySetup
from services.account_service import TenantService
@@ -63,7 +64,7 @@ def validate_init_password(payload: InitValidatePayload) -> InitValidateResponse
def get_init_validate_status() -> bool:
if dify_config.EDITION == "SELF_HOSTED":
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
if os.environ.get("INIT_PASSWORD"):
if session.get("is_init_validated"):
return True
+3 -2
View File
@@ -6,6 +6,7 @@ from sqlalchemy import select
from configs import dify_config
from controllers.fastopenapi import console_router
from enums.deployment_edition import DeploymentEdition
from libs.helper import EmailStr, extract_remote_ip
from libs.password import valid_password
from models.model import DifySetup, db
@@ -52,7 +53,7 @@ def get_setup_status_api() -> SetupStatusResponse:
Only bootstrap-safe status information should be returned by this endpoint.
"""
if dify_config.EDITION == "SELF_HOSTED":
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
setup_status = get_setup_status()
if setup_status and not isinstance(setup_status, bool):
return SetupStatusResponse(step="finished", setup_at=setup_status.setup_at.isoformat())
@@ -102,7 +103,7 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse:
def get_setup_status() -> DifySetup | bool | None:
if dify_config.EDITION == "SELF_HOSTED":
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return db.session.scalar(select(DifySetup).limit(1))
return True
+2 -1
View File
@@ -46,6 +46,7 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from enums.deployment_edition import DeploymentEdition
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.member_fields import AccountResponse
@@ -262,7 +263,7 @@ class AccountInitApi(Resource):
payload = console_ns.payload or {}
args = AccountInitPayload.model_validate(payload)
if dify_config.EDITION == "CLOUD":
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
if not args.invitation_code:
raise ValueError("invitation_code is required")
+1 -1
View File
@@ -198,7 +198,7 @@ def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_accou
if workspace_members.enabled is True and not workspace_members.is_available(new_member_count):
raise WorkspaceMembersLimitExceeded()
if new_account_count > 0:
seats = FeatureService.get_system_features(is_authenticated=True).license.seats
seats = FeatureService.get_license().seats
if not seats.is_available(new_account_count):
raise SeatsLimitExceeded()
return
@@ -37,6 +37,7 @@ from controllers.console.wraps import (
with_current_user,
)
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import dump_response, to_timestamp
@@ -233,7 +234,7 @@ class TenantListApi(Resource):
tenants = [tenant for tenant, _ in tenant_rows]
tenant_dicts = []
is_enterprise_only = dify_config.ENTERPRISE_ENABLED and not dify_config.BILLING_ENABLED
is_saas = dify_config.EDITION == "CLOUD" and dify_config.BILLING_ENABLED
is_saas = dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.BILLING_ENABLED
tenant_plans: dict[str, SubscriptionPlan] = {}
if is_saas:
+4 -3
View File
@@ -20,6 +20,7 @@ from controllers.common.wraps import (
from controllers.console.auth.error import AuthenticationFailedError, EmailCodeError
from controllers.console.workspace.error import AccountNotInitializedError
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from libs.encryption import FieldEncryption
@@ -129,7 +130,7 @@ def account_initialization_required[R](view: Callable[..., R]) -> Callable[...,
def only_edition_cloud[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
if dify_config.EDITION != "CLOUD":
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
abort(404)
return view(*args, **kwargs)
@@ -151,7 +152,7 @@ def only_edition_enterprise[**P, R](view: Callable[P, R]) -> Callable[P, R]:
def only_edition_self_hosted[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
if dify_config.EDITION != "SELF_HOSTED":
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
abort(404)
return view(*args, **kwargs)
@@ -327,7 +328,7 @@ def setup_required[R](view: Callable[..., R]) -> Callable[..., R]:
# The overloads keep Resource methods method-aware for pyrefly while
# preserving support for plain functions used in tests and utilities.
# check setup
if dify_config.EDITION == "SELF_HOSTED" and not _is_setup_completed():
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD and not _is_setup_completed():
if os.environ.get("INIT_PASSWORD"):
raise NotInitValidateError()
raise NotSetupError()
+2 -1
View File
@@ -8,6 +8,7 @@ from werkzeug.exceptions import InternalServerError
from configs import dify_config
from core.rbac import RBACPermission, RBACResourceScope
from enums.deployment_edition import DeploymentEdition
from libs.oauth_bearer import Scope, TokenType
from models.account import Account, Tenant, TenantAccountRole
from models.model import App, EndUser
@@ -26,7 +27,7 @@ class CallerKind(StrEnum):
def current_edition() -> Edition:
if dify_config.EDITION == "CLOUD":
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
return Edition.SAAS
if dify_config.ENTERPRISE_ENABLED:
return Edition.EE
+4 -1
View File
@@ -8,6 +8,7 @@ from configs import dify_config
from controllers.common.schema import register_response_schema_models
from controllers.web import web_ns
from controllers.web.wraps import WebApiResource
from enums.deployment_edition import DeploymentEdition
from extensions.ext_database import db
from extensions.storage.storage_type import StorageType
from fields.base import ResponseModel
@@ -127,7 +128,9 @@ def _build_site_icon_url(*, site: Site, tenant_id: str) -> str | None:
"""Use direct S3 URLs only in Cloud Mode and preserve preview URLs elsewhere."""
if site.icon_type != IconType.IMAGE or not site.icon:
return None
if dify_config.EDITION == "CLOUD" and StorageType(dify_config.STORAGE_TYPE) == StorageType.S3:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and (
StorageType(dify_config.STORAGE_TYPE) == StorageType.S3
):
return FileService(db.engine).get_file_presigned_url(file_id=site.icon, tenant_id=tenant_id)
return build_icon_url(site.icon_type, site.icon)
+2 -1
View File
@@ -6,6 +6,7 @@ from pydantic import BaseModel
from configs import dify_config
from core.entities import DEFAULT_PLUGIN_ID
from core.entities.provider_entities import ProviderQuotaType, QuotaUnit, RestrictModel
from enums.deployment_edition import DeploymentEdition
from graphon.model_runtime.entities.model_entities import ModelType
@@ -49,7 +50,7 @@ class HostingConfiguration:
self.moderation_config = None
def init_app(self, app: Flask):
if dify_config.EDITION != "CLOUD":
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return
self.provider_map[f"{DEFAULT_PLUGIN_ID}/azure_openai/azure_openai"] = self.init_azure_openai()
+3 -2
View File
@@ -34,6 +34,7 @@ from core.entities.provider_entities import (
from core.helper import encrypter
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
from core.helper.position_helper import is_filtered
from enums.deployment_edition import DeploymentEdition
from extensions import ext_hosting_provider
from extensions.ext_database import db
from extensions.ext_redis import redis_client
@@ -743,7 +744,7 @@ class ProviderManager:
if preferred_provider_type_record:
preferred_provider_type = preferred_provider_type_record.preferred_provider_type
elif dify_config.EDITION == "CLOUD" and system_configuration.enabled:
elif dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and system_configuration.enabled:
preferred_provider_type = ProviderType.SYSTEM
elif custom_configuration.provider or custom_configuration.models:
preferred_provider_type = ProviderType.CUSTOM
@@ -1538,7 +1539,7 @@ class ProviderManager:
quota_type_to_provider_records_dict[provider_record.quota_type] = provider_record # type: ignore[index]
quota_configurations = []
if dify_config.EDITION == "CLOUD":
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
from services.credit_pool_service import CreditPoolService
trail_pool = CreditPoolService.get_pool(
+11
View File
@@ -0,0 +1,11 @@
from enum import StrEnum
class DeploymentEdition(StrEnum):
"""
Enum representing the deployment edition of the platform.
"""
COMMUNITY = "COMMUNITY"
ENTERPRISE = "ENTERPRISE"
CLOUD = "CLOUD"
+31 -2
View File
@@ -9483,7 +9483,8 @@ data required for dashboard initialization.
Authentication would create circular dependency (can't login without dashboard loading).
Only non-sensitive configuration data should be returned by this endpoint.
Only non-sensitive configuration data should be returned by this endpoint. Authenticated
license detail is served separately by SystemFeatureLicenseApi.
#### Responses
@@ -9491,6 +9492,19 @@ Only non-sensitive configuration data should be returned by this endpoint.
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [SystemFeatureModel](#systemfeaturemodel)<br> |
### [GET] /system-features/license
**Get full license detail (status, expiry, workspace/seat usage)**
Get license status and usage detail
Authenticated counterpart to the license *status* exposed on the public
system-features endpoint.
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [LicenseModel](#licensemodel)<br> |
### [POST] /tag-bindings
#### Request Body
@@ -17318,6 +17332,14 @@ Default model entity.
| tool_name | string | | Yes |
| type | string | | Yes |
#### DeploymentEdition
Enum representing the deployment edition of the platform.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| DeploymentEdition | string | Enum representing the deployment edition of the platform. | |
#### DismissNotificationPayload
| Name | Type | Description | Required |
@@ -18815,6 +18837,12 @@ Enum class for large language model mode.
| ---- | ---- | ----------- | -------- |
| LicenseStatus | string | | |
#### LicenseStatusModel
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| status | [LicenseStatus](#licensestatus) | | Yes |
#### LimitationModel
| Name | Type | Description | Required |
@@ -22027,6 +22055,7 @@ 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 |
@@ -22043,7 +22072,7 @@ Model class for provider system configuration response.
| is_allow_register | boolean | | Yes |
| is_email_setup | boolean | | Yes |
| knowledge_fs_enabled | boolean | | Yes |
| license | [LicenseModel](#licensemodel) | | Yes |
| license | [LicenseStatusModel](#licensestatusmodel) | | Yes |
| max_plugin_package_size | integer, <br>**Default:** 15728640 | | Yes |
| plugin_installation_permission | [PluginInstallationPermissionModel](#plugininstallationpermissionmodel) | | Yes |
| plugin_manager | [PluginManagerModel](#pluginmanagermodel) | | Yes |
+16 -22
View File
@@ -1058,6 +1058,14 @@ 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
Enum representing the deployment edition of the platform.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| DeploymentEdition | string | Enum representing the deployment edition of the platform. | |
#### EmailCodeLoginSendPayload
| Name | Type | Description | Required |
@@ -1284,33 +1292,18 @@ Parsed multipart form fields for HITL uploads.
| ---- | ---- | ----------- | -------- |
| JsonValue | | | |
#### LicenseLimitationModel
- enabled: whether this limit is enforced
- size: current usage count
- limit: maximum allowed count; 0 means unlimited
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| enabled | boolean | Whether this limit is currently active | Yes |
| limit | integer | Maximum number of resources allowed; 0 means no limit | Yes |
| size | integer | Number of resources already consumed | Yes |
#### LicenseModel
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| expired_at | string | | Yes |
| seats | [LicenseLimitationModel](#licenselimitationmodel) | | Yes |
| status | [LicenseStatus](#licensestatus) | | Yes |
| workspaces | [LicenseLimitationModel](#licenselimitationmodel) | | Yes |
#### LicenseStatus
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| LicenseStatus | string | | |
#### LicenseStatusModel
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| status | [LicenseStatus](#licensestatus) | | Yes |
#### LoginPayload
| Name | Type | Description | Required |
@@ -1567,6 +1560,7 @@ 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 |
@@ -1583,7 +1577,7 @@ Default configuration for form inputs.
| is_allow_register | boolean | | Yes |
| is_email_setup | boolean | | Yes |
| knowledge_fs_enabled | boolean | | Yes |
| license | [LicenseModel](#licensemodel) | | Yes |
| license | [LicenseStatusModel](#licensestatusmodel) | | Yes |
| max_plugin_package_size | integer, <br>**Default:** 15728640 | | Yes |
| plugin_installation_permission | [PluginInstallationPermissionModel](#plugininstallationpermissionmodel) | | Yes |
| plugin_manager | [PluginManagerModel](#pluginmanagermodel) | | Yes |
+5 -5
View File
@@ -442,9 +442,9 @@ class AccountService:
# A licensed seat is one Account row, deployment-wide; joining an existing
# account into another workspace does not pass through here and costs no seat.
# is_authenticated=True: server-side enforcement needs the full license payload,
# which the enterprise fill withholds from unauthenticated (browser-facing) calls.
if not FeatureService.get_system_features(is_authenticated=True).license.seats.is_available():
# get_license() carries the full license payload that server-side enforcement needs;
# the public system-features endpoint exposes only license status.
if not FeatureService.get_license().seats.is_available():
raise SeatsLimitExceededError("licensed seats limit exceeded")
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email):
@@ -1332,7 +1332,7 @@ class TenantService:
):
raise WorkSpaceNotAllowedCreateError()
workspaces = FeatureService.get_system_features().license.workspaces
workspaces = FeatureService.get_license().workspaces
if not workspaces.is_available():
raise WorkspacesLimitExceededError()
@@ -2012,7 +2012,7 @@ class RegisterService:
if (
FeatureService.get_system_features().is_allow_create_workspace
and create_workspace_required
and FeatureService.get_system_features().license.workspaces.is_available()
and FeatureService.get_license().workspaces.is_available()
):
try:
TenantService.create_owner_tenant(account, session=session)
+50 -23
View File
@@ -5,6 +5,7 @@ from pydantic import BaseModel, ConfigDict, Field
from configs import dify_config
from constants.dsl_version import CURRENT_APP_DSL_VERSION
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from enums.hosted_provider import HostedTrialProvider
from services.billing_service import BillingInfo, BillingService
from services.enterprise.enterprise_service import EnterpriseService
@@ -75,8 +76,11 @@ class LicenseStatus(StrEnum):
LOST = "lost"
class LicenseModel(FeatureResponseModel):
class LicenseStatusModel(FeatureResponseModel):
status: LicenseStatus = LicenseStatus.NONE
class LicenseModel(LicenseStatusModel):
expired_at: str = ""
workspaces: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0)
seats: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0)
@@ -162,6 +166,7 @@ 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 = ""
@@ -174,7 +179,7 @@ class SystemFeatureModel(FeatureResponseModel):
is_allow_register: bool = False
is_allow_create_workspace: bool = False
is_email_setup: bool = False
license: LicenseModel = LicenseModel()
license: LicenseStatusModel = LicenseStatusModel()
branding: BrandingModel = BrandingModel()
webapp_auth: WebAppAuthModel = WebAppAuthModel()
plugin_installation_permission: PluginInstallationPermissionModel = PluginInstallationPermissionModel()
@@ -251,8 +256,8 @@ class FeatureService:
)
@classmethod
def get_system_features(cls, is_authenticated: bool = False) -> SystemFeatureModel:
system_features = SystemFeatureModel()
def get_system_features(cls) -> SystemFeatureModel:
system_features = SystemFeatureModel(deployment_edition=dify_config.DEPLOYMENT_EDITION)
system_features.rbac_enabled = dify_config.RBAC_ENABLED
cls._fulfill_system_params_from_env(system_features)
@@ -262,7 +267,7 @@ class FeatureService:
system_features.webapp_auth.enabled = True
system_features.enable_change_email = False
system_features.plugin_manager.enabled = True
cls._fulfill_params_from_enterprise(system_features, is_authenticated)
cls._fulfill_params_from_enterprise(system_features)
if dify_config.MARKETPLACE_ENABLED:
system_features.enable_marketplace = True
@@ -272,6 +277,17 @@ class FeatureService:
return system_features
@classmethod
def get_license(cls) -> LicenseModel:
"""Return full license detail. Enterprise-only; requires an authenticated caller.
Non-enterprise deployments have no license, so an unconstrained default
(unlimited seats/workspaces) is returned.
"""
if not dify_config.ENTERPRISE_ENABLED:
return LicenseModel()
return cls._build_license(EnterpriseService.get_info())
@classmethod
def get_app_dsl_version(cls) -> str:
return CURRENT_APP_DSL_VERSION
@@ -285,6 +301,7 @@ 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
@@ -410,7 +427,27 @@ class FeatureService:
vector_space.limit = billing_info["vector_space"]["limit"]
@classmethod
def _fulfill_params_from_enterprise(cls, features: SystemFeatureModel, is_authenticated: bool = False):
def _build_license(cls, enterprise_info: dict) -> LicenseModel:
license_model = LicenseModel()
if license_info := enterprise_info.get("License"):
license_model.status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
license_model.expired_at = license_info.get("expiredAt", "")
if workspaces_info := license_info.get("workspaces"):
license_model.workspaces = LicenseLimitationModel(
enabled=workspaces_info.get("enabled", False),
limit=workspaces_info.get("limit", 0),
size=workspaces_info.get("used", 0),
)
if seats_info := license_info.get("licensedSeats"):
license_model.seats = LicenseLimitationModel(
enabled=seats_info.get("enabled", False),
limit=seats_info.get("limit", 0),
size=seats_info.get("used", 0),
)
return license_model
@classmethod
def _fulfill_params_from_enterprise(cls, features: SystemFeatureModel):
enterprise_info = EnterpriseService.get_info()
if "SSOEnforcedForSignin" in enterprise_info:
@@ -450,24 +487,14 @@ class FeatureService:
)
features.webapp_auth.sso_config.protocol = enterprise_info.get("SSOEnforcedForWebProtocol", "")
# SECURITY NOTE: Only license *status* is exposed to unauthenticated callers
# so the login page can detect an expired/inactive license after force-logout.
# All other license details (expiry date, workspace usage) remain auth-gated.
# This behavior reflects prior internal review of information-leakage risks.
# SECURITY NOTE: system-features is unauthenticated, so it exposes only license
# *status* — enough for the login page to detect an expired/inactive license after
# force-logout. Full license detail (expiry, workspace/seat usage) is served
# separately by get_license() behind an authenticated endpoint.
if license_info := enterprise_info.get("License"):
features.license.status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
if is_authenticated:
features.license.expired_at = license_info.get("expiredAt", "")
if workspaces_info := license_info.get("workspaces"):
features.license.workspaces.enabled = workspaces_info.get("enabled", False)
features.license.workspaces.limit = workspaces_info.get("limit", 0)
features.license.workspaces.size = workspaces_info.get("used", 0)
if seats_info := license_info.get("licensedSeats"):
features.license.seats.enabled = seats_info.get("enabled", False)
features.license.seats.limit = seats_info.get("limit", 0)
features.license.seats.size = seats_info.get("used", 0)
features.license = LicenseStatusModel(
status=LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
)
if "PluginInstallationPermission" in enterprise_info:
plugin_installation_info = enterprise_info["PluginInstallationPermission"]
+2 -1
View File
@@ -4,6 +4,7 @@ from sqlalchemy.orm import Session
from configs import dify_config
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from models.account import Tenant, TenantAccountJoin, TenantAccountRole
from services.account_service import TenantService
from services.feature_service import FeatureService
@@ -60,7 +61,7 @@ class WorkspaceService:
"remove_webapp_brand": remove_webapp_brand,
"replace_webapp_logo": replace_webapp_logo,
}
if dify_config.EDITION == "CLOUD":
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
tenant_info["next_credit_reset_date"] = feature.next_credit_reset_date
from services.credit_pool_service import CreditPoolBalance, CreditPoolService
+2 -1
View File
@@ -1,10 +1,11 @@
from enum import StrEnum
from configs import dify_config
from enums.deployment_edition import DeploymentEdition
from services.workflow.entities import WorkflowScheduleCFSPlanEntity
# Determine queue names based on edition
if dify_config.EDITION == "CLOUD":
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
# Cloud edition: separate queues for different tiers
_professional_queue = "workflow_professional"
_team_queue = "workflow_team"
@@ -38,7 +38,8 @@ class TestAccountService:
# Setup default mock returns
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True
mock_feature_service.get_system_features.return_value.license.workspaces.is_available.return_value = True
mock_feature_service.get_license.return_value.workspaces.is_available.return_value = True
mock_feature_service.get_license.return_value.seats.is_available.return_value = True
mock_billing_service.is_email_in_freeze.return_value = False
mock_passport_service.return_value.issue.return_value = "mock_jwt_token"
@@ -405,7 +406,7 @@ class TestAccountService:
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
].get_license.return_value.workspaces.is_available.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
account = AccountService.create_account_and_tenant(
email=email,
@@ -466,7 +467,7 @@ class TestAccountService:
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = False
].get_license.return_value.workspaces.is_available.return_value = False
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
with pytest.raises(WorkspacesLimitExceededError):
@@ -492,7 +493,7 @@ class TestAccountService:
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.seats.is_available.return_value = False
].get_license.return_value.seats.is_available.return_value = False
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
with pytest.raises(SeatsLimitExceededError):
@@ -1274,7 +1275,8 @@ class TestTenantService:
):
# Setup default mock returns
mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True
mock_feature_service.get_system_features.return_value.license.workspaces.is_available.return_value = True
mock_feature_service.get_license.return_value.workspaces.is_available.return_value = True
mock_feature_service.get_license.return_value.seats.is_available.return_value = True
mock_billing_service.is_email_in_freeze.return_value = False
yield {
@@ -2244,7 +2246,7 @@ class TestTenantService:
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
].get_license.return_value.workspaces.is_available.return_value = True
# Create account
account = AccountService.create_account(
@@ -2285,7 +2287,7 @@ class TestTenantService:
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
].get_license.return_value.workspaces.is_available.return_value = True
# Create account and existing tenant
account = AccountService.create_account(
@@ -2514,7 +2516,8 @@ class TestRegisterService:
# Setup default mock returns
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True
mock_feature_service.get_system_features.return_value.license.workspaces.is_available.return_value = True
mock_feature_service.get_license.return_value.workspaces.is_available.return_value = True
mock_feature_service.get_license.return_value.seats.is_available.return_value = True
mock_billing_service.is_email_in_freeze.return_value = False
mock_passport_service.return_value.issue.return_value = "mock_jwt_token"
@@ -2631,7 +2634,7 @@ class TestRegisterService:
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
].get_license.return_value.workspaces.is_available.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Execute registration
@@ -2675,7 +2678,7 @@ class TestRegisterService:
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
].get_license.return_value.workspaces.is_available.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Execute registration with OAuth
@@ -2724,7 +2727,7 @@ class TestRegisterService:
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
].get_license.return_value.workspaces.is_available.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Execute registration with pending status
@@ -2809,7 +2812,7 @@ class TestRegisterService:
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = False
].get_license.return_value.workspaces.is_available.return_value = False
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# with pytest.raises(AccountRegisterError, match="Workspace is not allowed to create."):
@@ -2888,7 +2891,7 @@ class TestRegisterService:
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
].get_license.return_value.workspaces.is_available.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and inviter account
@@ -5,10 +5,12 @@ from faker import Faker
from sqlalchemy.orm import Session
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from services.feature_service import (
FeatureModel,
FeatureService,
KnowledgeRateLimitModel,
LicenseModel,
LicenseStatus,
SystemFeatureModel,
)
@@ -273,6 +275,7 @@ class TestFeatureService:
tenant_id = self._create_test_tenant_id()
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = True
mock_config.ENABLE_EMAIL_CODE_LOGIN = True
@@ -285,7 +288,7 @@ class TestFeatureService:
mock_config.PLUGIN_MAX_PACKAGE_SIZE = 100
# Act: Execute the method under test
result = FeatureService.get_system_features(is_authenticated=True)
result = FeatureService.get_system_features()
# Assert: Verify the expected outcomes
assert result is not None
@@ -320,12 +323,9 @@ class TestFeatureService:
assert result.webapp_auth.allow_email_password_login is False
assert result.webapp_auth.sso_config.protocol == "oidc"
# Verify license configuration
# Verify license status (public system-features exposes status only; detail lives on get_license)
assert result.license.status.value == "active"
assert result.license.expired_at == "2025-12-31"
assert result.license.workspaces.enabled is True
assert result.license.workspaces.limit == 5
assert result.license.workspaces.size == 2
assert not hasattr(result.license, "expired_at")
# Verify plugin installation permission
assert result.plugin_installation_permission.plugin_installation_scope == "official_only"
@@ -350,6 +350,7 @@ class TestFeatureService:
"""
# Arrange: Setup test data with exact same config as success test
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = True
mock_config.ENABLE_EMAIL_CODE_LOGIN = True
@@ -360,20 +361,18 @@ class TestFeatureService:
mock_config.MAIL_TYPE = "smtp"
mock_config.PLUGIN_MAX_PACKAGE_SIZE = 100
# Act: Execute with is_authenticated=False
result = FeatureService.get_system_features(is_authenticated=False)
# Act: Execute the public (unauthenticated) system-features call
result = FeatureService.get_system_features()
# Assert: Basic structure
assert result is not None
assert isinstance(result, SystemFeatureModel)
# --- 1. Verify only license *status* is exposed to unauthenticated clients ---
# Detailed license info (expiry, workspaces) remains auth-gated.
# Detailed license info (expiry, workspaces) is not part of the public model.
assert result.license.status == LicenseStatus.ACTIVE
assert result.license.expired_at == ""
assert result.license.workspaces.enabled is False
assert result.license.workspaces.limit == 0
assert result.license.workspaces.size == 0
assert not hasattr(result.license, "expired_at")
assert not hasattr(result.license, "workspaces")
# --- 2. Verify Public UI Configuration Availability ---
# Ensure that data required for frontend rendering remains accessible.
@@ -394,6 +393,47 @@ class TestFeatureService:
# Marketplace should be visible
assert result.enable_marketplace is True
def test_get_license_returns_full_detail(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test the authenticated license accessor.
This test verifies that:
- get_license() returns the full license payload (status, expiry, workspace usage).
- Detail withheld from the public system-features model is present here.
"""
# Arrange
with patch("services.feature_service.dify_config") as mock_config:
mock_config.ENTERPRISE_ENABLED = True
# Act
result = FeatureService.get_license()
# Assert: full license detail is populated
assert isinstance(result, LicenseModel)
assert result.status == LicenseStatus.ACTIVE
assert result.expired_at == "2025-12-31"
assert result.workspaces.enabled is True
assert result.workspaces.limit == 5
assert result.workspaces.size == 2
mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once()
def test_get_license_non_enterprise_is_unconstrained(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""Non-enterprise deployments have no license, so limits are unconstrained."""
with patch("services.feature_service.dify_config") as mock_config:
mock_config.ENTERPRISE_ENABLED = False
result = FeatureService.get_license()
assert isinstance(result, LicenseModel)
assert result.status == LicenseStatus.NONE
assert result.workspaces.is_available() is True
assert result.seats.is_available() is True
mock_external_service_dependencies["enterprise_service"].get_info.assert_not_called()
def test_get_system_features_basic_config(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
@@ -408,12 +448,14 @@ class TestFeatureService:
"""
# Arrange: Setup basic config mock (no enterprise)
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
mock_config.ENTERPRISE_ENABLED = False
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = True
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"
@@ -611,11 +653,13 @@ class TestFeatureService:
"""
# Arrange: Setup enterprise disabled mock
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
mock_config.ENTERPRISE_ENABLED = False
mock_config.MARKETPLACE_ENABLED = True
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
@@ -650,8 +694,8 @@ class TestFeatureService:
# Verify default license status
assert result.license.status == "none"
assert result.license.expired_at == ""
assert result.license.workspaces.enabled is False
assert not hasattr(result.license, "expired_at")
assert not hasattr(result.license, "workspaces")
# Verify no enterprise service calls
mock_external_service_dependencies["enterprise_service"].get_info.assert_not_called()
@@ -840,6 +884,7 @@ class TestFeatureService:
"""
# Arrange: Setup edge case webapp auth mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@@ -960,6 +1005,7 @@ class TestFeatureService:
# Test case 1: Official only scope
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@@ -983,6 +1029,7 @@ class TestFeatureService:
# Test case 2: All plugins scope
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@@ -1003,6 +1050,7 @@ class TestFeatureService:
# Test case 3: Specific partners scope
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@@ -1026,6 +1074,7 @@ class TestFeatureService:
# Test case 4: None scope
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@@ -1118,24 +1167,19 @@ class TestFeatureService:
}
}
# Act: Execute the method under test
result = FeatureService.get_system_features(is_authenticated=True)
# Act: Execute the authenticated license accessor
result = FeatureService.get_license()
# Assert: Verify the expected outcomes
assert result is not None
assert isinstance(result, SystemFeatureModel)
assert isinstance(result, LicenseModel)
# Verify license status
assert result.license.status == "inactive"
assert result.license.expired_at == ""
assert result.license.workspaces.enabled is False
assert result.license.workspaces.size == 0
assert result.license.workspaces.limit == 0
# Verify enterprise features
assert result.branding.enabled is True
assert result.webapp_auth.enabled is True
assert result.enable_change_email is False
# Verify license status and detail
assert result.status == "inactive"
assert result.expired_at == ""
assert result.workspaces.enabled is False
assert result.workspaces.size == 0
assert result.workspaces.limit == 0
# Verify mock interactions
mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once()
@@ -1154,6 +1198,7 @@ class TestFeatureService:
"""
# Arrange: Setup partial enterprise info mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@@ -1200,8 +1245,8 @@ class TestFeatureService:
# Verify default license status
assert result.license.status == "none"
assert result.license.expired_at == ""
assert result.license.workspaces.enabled is False
assert not hasattr(result.license, "expired_at")
assert not hasattr(result.license, "workspaces")
# Verify default plugin installation permission
assert result.plugin_installation_permission.plugin_installation_scope == "all"
@@ -1283,6 +1328,7 @@ class TestFeatureService:
"""
# Arrange: Setup edge case protocols mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@@ -1493,24 +1539,19 @@ class TestFeatureService:
}
}
# Act: Execute the method under test
result = FeatureService.get_system_features(is_authenticated=True)
# Act: Execute the authenticated license accessor
result = FeatureService.get_license()
# Assert: Verify the expected outcomes
assert result is not None
assert isinstance(result, SystemFeatureModel)
assert isinstance(result, LicenseModel)
# Verify license status
assert result.license.status == "expired"
assert result.license.expired_at == "2023-12-31"
assert result.license.workspaces.enabled is False
assert result.license.workspaces.size == 0
assert result.license.workspaces.limit == 0
# Verify enterprise features
assert result.branding.enabled is True
assert result.webapp_auth.enabled is True
assert result.enable_change_email is False
# Verify license status and detail
assert result.status == "expired"
assert result.expired_at == "2023-12-31"
assert result.workspaces.enabled is False
assert result.workspaces.size == 0
assert result.workspaces.limit == 0
# Verify mock interactions
mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once()
@@ -1587,6 +1628,7 @@ class TestFeatureService:
"""
# Arrange: Setup edge case branding mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@@ -1776,6 +1818,7 @@ class TestFeatureService:
"""
# Arrange: Setup lost license mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@@ -1787,7 +1830,7 @@ class TestFeatureService:
mock_config.PLUGIN_MAX_PACKAGE_SIZE = 100
mock_external_service_dependencies["enterprise_service"].get_info.return_value = {
"license": {"status": "lost", "expired_at": None, "plan": None}
"License": {"status": "lost"}
}
# Act: Execute the method under test
@@ -1809,6 +1852,7 @@ class TestFeatureService:
assert result.enable_email_password_login is True
assert result.is_allow_register is False
assert result.is_allow_create_workspace is False
assert result.license.status == LicenseStatus.LOST
# Verify mock interactions
mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once()
@@ -6,6 +6,7 @@ import pytest
from faker import Faker
from sqlalchemy.orm import Session
from enums.deployment_edition import DeploymentEdition
from models import Account, Tenant, TenantAccountJoin, TenantAccountRole
from services.credit_pool_service import CreditPoolBalance
from services.workspace_service import WorkspaceService
@@ -611,7 +612,7 @@ class TestWorkspaceService:
db_session_with_containers, mock_external_service_dependencies
)
mock_external_service_dependencies["dify_config"].EDITION = "SELF_HOSTED"
mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
mock_external_service_dependencies["feature_service"].get_features.return_value.can_replace_logo = False
mock_external_service_dependencies["tenant_service"].has_roles.return_value = False
@@ -632,7 +633,7 @@ class TestWorkspaceService:
db_session_with_containers, mock_external_service_dependencies
)
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
feature.can_replace_logo = False
feature.next_credit_reset_date = "2025-02-01"
@@ -657,7 +658,7 @@ class TestWorkspaceService:
db_session_with_containers, mock_external_service_dependencies
)
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
feature.can_replace_logo = False
feature.next_credit_reset_date = "2025-02-01"
@@ -685,7 +686,7 @@ class TestWorkspaceService:
db_session_with_containers, mock_external_service_dependencies
)
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
feature.can_replace_logo = False
feature.next_credit_reset_date = "2025-02-01"
@@ -713,7 +714,7 @@ class TestWorkspaceService:
db_session_with_containers, mock_external_service_dependencies
)
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
feature.can_replace_logo = False
feature.next_credit_reset_date = "2025-02-01"
@@ -749,7 +750,7 @@ class TestWorkspaceService:
db_session_with_containers, mock_external_service_dependencies
)
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
feature.can_replace_logo = False
feature.next_credit_reset_date = "2025-02-01"
@@ -779,7 +780,7 @@ class TestWorkspaceService:
db_session_with_containers, mock_external_service_dependencies
)
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
feature.can_replace_logo = False
feature.next_credit_reset_date = "2025-02-01"
@@ -808,7 +809,7 @@ class TestWorkspaceService:
db_session_with_containers, mock_external_service_dependencies
)
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
feature.can_replace_logo = False
feature.next_credit_reset_date = "2025-02-01"
@@ -13,6 +13,7 @@ from sqlalchemy import Engine, event
from sqlalchemy.orm import Session
from controllers.console.app import app_import as app_import_module
from enums.deployment_edition import DeploymentEdition
from models.account import Account
from models.base import TypeBase
from models.engine import db
@@ -47,7 +48,10 @@ class _Result:
def _install_features(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None:
features = SystemFeatureModel(webapp_auth=WebAppAuthModel(enabled=enabled))
features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
webapp_auth=WebAppAuthModel(enabled=enabled),
)
monkeypatch.setattr(app_import_module.FeatureService, "get_system_features", lambda: features)
@@ -11,6 +11,7 @@ from controllers.console.auth.email_register import (
EmailRegisterResetApi,
EmailRegisterSendEmailApi,
)
from enums.deployment_edition import DeploymentEdition
from services.feature_service import SystemFeatureModel
@@ -34,7 +35,11 @@ class TestEmailRegisterSendEmailApi:
mock_account = MagicMock()
mock_get_account.return_value = mock_account
feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
feature_flags = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
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"),
@@ -75,7 +80,11 @@ class TestEmailRegisterCheckApi:
mock_get_data.return_value = {"email": "User@Example.com", "code": "4321"}
mock_generate_token.return_value = (None, "new-token")
feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
feature_flags = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
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),
@@ -123,7 +132,11 @@ class TestEmailRegisterResetApi:
mock_login.return_value = token_pair
mock_get_account.return_value = None
feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
feature_flags = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
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),
@@ -171,7 +184,11 @@ class TestEmailRegisterResetApi:
mock_login.return_value = token_pair
mock_get_account.return_value = None
feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
feature_flags = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
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),
@@ -224,7 +241,11 @@ class TestEmailRegisterResetApi:
mock_login.return_value = token_pair
mock_get_account.return_value = None
feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
feature_flags = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
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),
@@ -485,10 +485,12 @@ class TestEmailCodeLoginApi:
@patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token")
@patch("controllers.console.auth.login.AccountService.get_user_through_email")
@patch("controllers.console.auth.login.TenantService.get_join_tenants")
@patch("controllers.console.auth.login.FeatureService.get_license")
@patch("controllers.console.auth.login.FeatureService.get_system_features")
def test_email_code_login_workspace_limit_exceeded(
self,
mock_get_features,
mock_get_license,
mock_get_tenants,
mock_get_user,
mock_revoke_token,
@@ -507,9 +509,7 @@ class TestEmailCodeLoginApi:
mock_get_data.return_value = {"email": "test@example.com", "code": "123456"}
mock_get_user.return_value = mock_account
mock_get_tenants.return_value = []
mock_features = MagicMock()
mock_features.license.workspaces.is_available.return_value = False
mock_get_features.return_value = mock_features
mock_get_license.return_value.workspaces.is_available.return_value = False
# Act & Assert
with app.test_request_context(
@@ -13,6 +13,7 @@ from controllers.console.auth.forgot_password import (
ForgotPasswordResetApi,
ForgotPasswordSendEmailApi,
)
from enums.deployment_edition import DeploymentEdition
from models.account import Account
from models.engine import db
from services.feature_service import SystemFeatureModel
@@ -46,8 +47,15 @@ class TestForgotPasswordSendEmailApi:
mock_get_account.return_value = mock_account
mock_send_email.return_value = "token-123"
wraps_features = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
controller_features = SystemFeatureModel(is_allow_register=True)
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,
)
with (
patch(
"controllers.console.auth.forgot_password.FeatureService.get_system_features",
@@ -95,7 +103,10 @@ class TestForgotPasswordCheckApi:
mock_get_data.return_value = {"email": "Admin@Example.com", "code": "4321"}
mock_generate_token.return_value = (None, "new-token")
wraps_features = SystemFeatureModel(enable_email_password_login=True)
wraps_features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
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),
@@ -138,7 +149,10 @@ class TestForgotPasswordResetApi:
db.session.commit()
mock_get_account.return_value = account
wraps_features = SystemFeatureModel(enable_email_password_login=True)
wraps_features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
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),
@@ -343,10 +343,12 @@ class TestLoginApi:
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.login.AccountService.authenticate")
@patch("controllers.console.auth.login.TenantService.get_join_tenants")
@patch("controllers.console.auth.login.FeatureService.get_license")
@patch("controllers.console.auth.login.FeatureService.get_system_features")
def test_login_fails_when_no_workspace_and_limit_exceeded(
self,
mock_get_features: MagicMock,
mock_get_license: MagicMock,
mock_get_tenants: MagicMock,
mock_authenticate: MagicMock,
mock_get_invitation: MagicMock,
@@ -370,8 +372,8 @@ class TestLoginApi:
mock_features = MagicMock()
mock_features.is_allow_create_workspace = True
mock_features.license.workspaces.is_available.return_value = False
mock_get_features.return_value = mock_features
mock_get_license.return_value.workspaces.is_available.return_value = False
# Act & Assert
with app.test_request_context(
@@ -23,6 +23,7 @@ from controllers.console.auth.forgot_password import (
ForgotPasswordSendEmailApi,
)
from controllers.console.error import AccountNotFound, EmailSendIpLimitError
from enums.deployment_edition import DeploymentEdition
from models.account import Account, Tenant, TenantAccountJoin
from services.feature_service import SystemFeatureModel
@@ -48,7 +49,10 @@ 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(enable_email_password_login=True),
lambda: SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
),
)
@@ -2,14 +2,15 @@ from inspect import unwrap
from pytest_mock import MockerFixture
from models import Account
from services.feature_service import FeatureModel, LimitationModel, SystemFeatureModel
def make_account() -> Account:
account = Account(name="Alice", email="alice@example.com")
account.id = "account-1"
return account
from enums.deployment_edition import DeploymentEdition
from services.feature_service import (
FeatureModel,
LicenseLimitationModel,
LicenseModel,
LicenseStatus,
LimitationModel,
SystemFeatureModel,
)
class TestFeatureApi:
@@ -82,19 +83,16 @@ class TestAppDslVersionApi:
class TestSystemFeatureApi:
def test_get_system_features_authenticated(self, mocker: MockerFixture):
"""
current_user.is_authenticated == True
"""
def test_get_system_features_public(self, mocker: MockerFixture):
"""The public endpoint returns system features without any authentication input."""
from controllers.console.feature import SystemFeatureApi
account = make_account()
current_account = mocker.patch(
"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,
@@ -105,29 +103,28 @@ class TestSystemFeatureApi:
assert result == system_features.model_dump()
assert result["enable_learn_app"] is True
current_account.assert_called_once_with()
get_system_features.assert_called_once_with(is_authenticated=True)
assert result["license"] == {"status": LicenseStatus.NONE}
get_system_features.assert_called_once_with()
def test_get_system_features_unauthenticated(self, mocker: MockerFixture):
"""
current_user.is_authenticated raises Unauthorized
"""
from controllers.console.feature import SystemFeatureApi
class TestSystemFeatureLicenseApi:
def test_get_license_success(self, mocker: MockerFixture):
from controllers.console.feature import SystemFeatureLicenseApi
current_account = mocker.patch(
"controllers.console.feature.current_account_with_tenant_optional",
return_value=(None, None),
license_model = LicenseModel(
status=LicenseStatus.ACTIVE,
expired_at="2025-12-31",
seats=LicenseLimitationModel(enabled=True, limit=5, size=2),
)
system_features = SystemFeatureModel(is_allow_register=False)
get_system_features = mocker.patch(
"controllers.console.feature.FeatureService.get_system_features",
return_value=system_features,
get_license = mocker.patch(
"controllers.console.feature.FeatureService.get_license",
return_value=license_model,
)
api = SystemFeatureApi()
result = api.get()
api = SystemFeatureLicenseApi()
raw_get = unwrap(SystemFeatureLicenseApi.get)
result = raw_get(api)
assert result == system_features.model_dump()
current_account.assert_called_once_with()
get_system_features.assert_called_once_with(is_authenticated=False)
assert result == license_model.model_dump()
assert result["seats"] == {"enabled": True, "limit": 5, "size": 2}
get_license.assert_called_once_with()
@@ -323,8 +323,8 @@ class TestMemberInviteEmailApi:
features = MagicMock()
features.billing.enabled = False
features.workspace_members.enabled = False
system_features = MagicMock()
system_features.license.seats.is_available.return_value = False
license_info = MagicMock()
license_info.seats.is_available.return_value = False
payload = {
"emails": ["a@test.com", "b@test.com"],
@@ -336,9 +336,9 @@ class TestMemberInviteEmailApi:
patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=(2, 2)),
patch(
"controllers.console.workspace.members.FeatureService.get_system_features",
return_value=system_features,
) as mock_get_system_features,
"controllers.console.workspace.members.FeatureService.get_license",
return_value=license_info,
) as mock_get_license,
patch("controllers.console.workspace.members.RegisterService.invite_new_member") as mock_invite,
patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", True),
patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False),
@@ -346,8 +346,8 @@ class TestMemberInviteEmailApi:
with pytest.raises(SeatsLimitExceeded):
method(api, user)
mock_get_system_features.assert_called_once_with(is_authenticated=True)
system_features.license.seats.is_available.assert_called_once_with(2)
mock_get_license.assert_called_once_with()
license_info.seats.is_available.assert_called_once_with(2)
mock_invite.assert_not_called()
def test_invite_existing_accounts_do_not_consume_seats(self, app: Flask):
@@ -359,8 +359,8 @@ class TestMemberInviteEmailApi:
features = MagicMock()
features.billing.enabled = False
features.workspace_members.enabled = False
system_features = MagicMock()
system_features.license.seats.is_available.return_value = False
license_info = MagicMock()
license_info.seats.is_available.return_value = False
payload = {
"emails": ["a@test.com", "b@test.com"],
@@ -372,9 +372,9 @@ class TestMemberInviteEmailApi:
patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=(2, 0)),
patch(
"controllers.console.workspace.members.FeatureService.get_system_features",
return_value=system_features,
) as mock_get_system_features,
"controllers.console.workspace.members.FeatureService.get_license",
return_value=license_info,
) as mock_get_license,
patch(
"controllers.console.workspace.members.RegisterService.invite_new_member", return_value="token"
) as mock_invite,
@@ -386,8 +386,8 @@ class TestMemberInviteEmailApi:
assert status == 201
assert len(result["invitation_results"]) == 2
mock_get_system_features.assert_not_called()
system_features.license.seats.is_available.assert_not_called()
mock_get_license.assert_not_called()
license_info.seats.is_available.assert_not_called()
assert mock_invite.call_count == 2
def test_invite_mixed_accounts_with_available_seats(self, app: Flask):
@@ -399,8 +399,8 @@ class TestMemberInviteEmailApi:
features = MagicMock()
features.billing.enabled = False
features.workspace_members.enabled = False
system_features = MagicMock()
system_features.license.seats.is_available.return_value = True
license_info = MagicMock()
license_info.seats.is_available.return_value = True
payload = {
"emails": ["a@test.com", "b@test.com"],
@@ -412,9 +412,9 @@ class TestMemberInviteEmailApi:
patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=(2, 1)),
patch(
"controllers.console.workspace.members.FeatureService.get_system_features",
return_value=system_features,
) as mock_get_system_features,
"controllers.console.workspace.members.FeatureService.get_license",
return_value=license_info,
) as mock_get_license,
patch(
"controllers.console.workspace.members.RegisterService.invite_new_member", return_value="token"
) as mock_invite,
@@ -426,8 +426,8 @@ class TestMemberInviteEmailApi:
assert status == 201
assert len(result["invitation_results"]) == 2
mock_get_system_features.assert_called_once_with(is_authenticated=True)
system_features.license.seats.is_available.assert_called_once_with(1)
mock_get_license.assert_called_once_with()
license_info.seats.is_available.assert_called_once_with(1)
assert mock_invite.call_count == 2
def test_invite_skips_seats_limit_when_enterprise_disabled(self, app: Flask):
@@ -439,8 +439,8 @@ class TestMemberInviteEmailApi:
features = MagicMock()
features.billing.enabled = False
features.workspace_members.enabled = False
system_features = MagicMock()
system_features.license.seats.is_available.return_value = False
license_info = MagicMock()
license_info.seats.is_available.return_value = False
payload = {
"emails": ["a@test.com"],
@@ -452,9 +452,9 @@ class TestMemberInviteEmailApi:
patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=(1, 1)),
patch(
"controllers.console.workspace.members.FeatureService.get_system_features",
return_value=system_features,
) as mock_get_system_features,
"controllers.console.workspace.members.FeatureService.get_license",
return_value=license_info,
) as mock_get_license,
patch("controllers.console.workspace.members.RegisterService.invite_new_member", return_value="token"),
patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "http://x"),
patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False),
@@ -464,8 +464,8 @@ class TestMemberInviteEmailApi:
assert status == 201
assert result["invitation_results"][0]["status"] == "success"
mock_get_system_features.assert_not_called()
system_features.license.seats.is_available.assert_not_called()
mock_get_license.assert_not_called()
license_info.seats.is_available.assert_not_called()
def test_invite_seats_error_is_reported_as_failed_result(self, app: Flask):
api = MemberInviteEmailApi()
@@ -476,8 +476,8 @@ class TestMemberInviteEmailApi:
features = MagicMock()
features.billing.enabled = False
features.workspace_members.enabled = False
system_features = MagicMock()
system_features.license.seats.is_available.return_value = True
license_info = MagicMock()
license_info.seats.is_available.return_value = True
payload = {
"emails": ["a@test.com"],
@@ -489,8 +489,8 @@ class TestMemberInviteEmailApi:
patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=(1, 1)),
patch(
"controllers.console.workspace.members.FeatureService.get_system_features",
return_value=system_features,
"controllers.console.workspace.members.FeatureService.get_license",
return_value=license_info,
),
patch(
"controllers.console.workspace.members.RegisterService.invite_new_member",
@@ -11,26 +11,27 @@ from controllers.openapi.auth.data import (
RequestContext,
current_edition,
)
from enums.deployment_edition import DeploymentEdition
from libs.oauth_bearer import Scope, TokenType
def test_current_edition_saas():
with patch("controllers.openapi.auth.data.dify_config") as cfg:
cfg.EDITION = "CLOUD"
cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
cfg.ENTERPRISE_ENABLED = True
assert current_edition() == Edition.SAAS
def test_current_edition_ee():
with patch("controllers.openapi.auth.data.dify_config") as cfg:
cfg.EDITION = "SELF_HOSTED"
cfg.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
cfg.ENTERPRISE_ENABLED = True
assert current_edition() == Edition.EE
def test_current_edition_ce():
with patch("controllers.openapi.auth.data.dify_config") as cfg:
cfg.EDITION = "SELF_HOSTED"
cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
cfg.ENTERPRISE_ENABLED = False
assert current_edition() == Edition.CE
@@ -14,6 +14,7 @@ from controllers.web.forgot_password import (
ForgotPasswordResetApi,
ForgotPasswordSendEmailApi,
)
from enums.deployment_edition import DeploymentEdition
from models.account import Account
from models.engine import db
from services.feature_service import SystemFeatureModel
@@ -32,7 +33,10 @@ def database_app() -> Iterator[Flask]:
@pytest.fixture(autouse=True)
def _patch_wraps():
wraps_features = SystemFeatureModel(enable_email_password_login=True)
wraps_features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
)
with (
patch("controllers.console.wraps.db") as mock_db,
patch("controllers.console.wraps.dify_config.ENTERPRISE_ENABLED", True),
@@ -10,6 +10,7 @@ from werkzeug.exceptions import Unauthorized
import services.errors.account
from controllers.web.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi, LoginApi, LoginStatusApi, LogoutApi
from enums.deployment_edition import DeploymentEdition
from services.entities.auth_entities import LoginFailureReason
@@ -34,7 +35,7 @@ def app():
@pytest.fixture(autouse=True)
def _patch_wraps():
wraps_features = SimpleNamespace(enable_email_password_login=True)
console_dify = SimpleNamespace(ENTERPRISE_ENABLED=True, EDITION="CLOUD")
console_dify = SimpleNamespace(ENTERPRISE_ENABLED=True, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
web_dify = SimpleNamespace(ENTERPRISE_ENABLED=True)
with (
patch("controllers.console.wraps.db") as mock_db,
@@ -19,6 +19,7 @@ from sqlalchemy.orm import Session
from core.plugin.entities.plugin import PluginInstallationSource
from core.plugin.entities.plugin_daemon import PluginVerification
from core.plugin.plugin_service import PluginService
from enums.deployment_edition import DeploymentEdition
from models import ProviderType
from models.engine import db
from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider
@@ -35,10 +36,11 @@ 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,
)
),
)
@@ -808,7 +808,7 @@ class TestTenantService:
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
].get_license.return_value.workspaces.is_available.return_value = True
mock_rsa_dependencies.return_value = "mock_public_key"
with (
@@ -1028,7 +1028,7 @@ class TestTenantService:
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
].get_license.return_value.workspaces.is_available.return_value = True
mock_tenant = MagicMock()
mock_tenant.id = "tenant-rbac"
@@ -1481,7 +1481,7 @@ class TestRegisterService:
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
].get_license.return_value.workspaces.is_available.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Mock AccountService.create_account
@@ -1590,7 +1590,7 @@ class TestRegisterService:
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
].get_license.return_value.workspaces.is_available.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
mock_account = TestAccountAssociatedDataFactory.create_account_mock(
@@ -1629,7 +1629,7 @@ class TestRegisterService:
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
].get_license.return_value.workspaces.is_available.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
mock_account = TestAccountAssociatedDataFactory.create_account_mock(
@@ -1664,7 +1664,7 @@ class TestRegisterService:
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
].get_license.return_value.workspaces.is_available.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Mock AccountService.create_account and link_account_integrate
@@ -1708,7 +1708,7 @@ class TestRegisterService:
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
].get_license.return_value.workspaces.is_available.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Mock AccountService.create_account
@@ -1750,7 +1750,7 @@ class TestRegisterService:
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
].get_license.return_value.workspaces.is_available.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Mock AccountService.create_account
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,35 @@
import pytest
from pydantic import ValidationError
from enums.deployment_edition import DeploymentEdition
from services.feature_service import 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,5 +1,6 @@
import pytest
from enums.deployment_edition import DeploymentEdition
from services import feature_service as feature_service_module
from services.feature_service import FeatureService, SystemFeatureModel
@@ -29,7 +30,7 @@ def test_fulfill_params_from_enterprise_enable_app_deploy(
staticmethod(lambda: enterprise_info),
)
features = SystemFeatureModel()
features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY)
features.enable_app_deploy = initial
FeatureService._fulfill_params_from_enterprise(features)
@@ -1,10 +1,11 @@
import pytest
from enums.deployment_edition import DeploymentEdition
from services.feature_service import FeatureService, SystemFeatureModel
def test_system_feature_model_disables_knowledge_fs_by_default() -> None:
assert SystemFeatureModel().knowledge_fs_enabled is False
assert SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY).knowledge_fs_enabled is False
@pytest.mark.parametrize("enabled", [False, True])
@@ -1,12 +1,15 @@
import pytest
from enums.deployment_edition import DeploymentEdition
from services import feature_service as feature_service_module
from services.feature_service import FeatureService, SystemFeatureModel
def test_system_feature_model_defaults_enable_learn_app():
assert SystemFeatureModel().enable_learn_app is True
assert SystemFeatureModel().enable_step_by_step_tour is False
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
@pytest.mark.parametrize("enabled", [True, False])
@@ -1,38 +1,34 @@
import pytest
from services import feature_service as feature_service_module
from services.feature_service import FeatureService, SystemFeatureModel
from services.feature_service import FeatureService, LicenseModel, LicenseStatus
_ENTERPRISE_INFO = {"License": {"licensedSeats": {"enabled": True, "limit": 3, "used": 1}}}
def test_fulfill_params_from_enterprise_parses_licensed_seats(monkeypatch: pytest.MonkeyPatch):
"""Authenticated fill copies the licensed-seat quota out of the enterprise payload."""
def test_get_license_parses_licensed_seats(monkeypatch: pytest.MonkeyPatch):
"""The authenticated license accessor copies the licensed-seat quota out of the enterprise payload."""
monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", True)
monkeypatch.setattr(
feature_service_module.EnterpriseService,
"get_info",
staticmethod(lambda: _ENTERPRISE_INFO),
)
features = SystemFeatureModel()
FeatureService._fulfill_params_from_enterprise(features, is_authenticated=True)
license_model = FeatureService.get_license()
assert features.license.seats.enabled is True
assert features.license.seats.limit == 3
assert features.license.seats.size == 1
assert isinstance(license_model, LicenseModel)
assert license_model.seats.enabled is True
assert license_model.seats.limit == 3
assert license_model.seats.size == 1
def test_fulfill_params_from_enterprise_withholds_seats_when_unauthenticated(monkeypatch: pytest.MonkeyPatch):
"""Seat counts are auth-gated: unauthenticated callers keep the zeroed default."""
monkeypatch.setattr(
feature_service_module.EnterpriseService,
"get_info",
staticmethod(lambda: _ENTERPRISE_INFO),
)
def test_get_license_non_enterprise_is_unconstrained(monkeypatch: pytest.MonkeyPatch):
"""Non-enterprise deployments have no license; seat allocation is unconstrained."""
monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", False)
features = SystemFeatureModel()
FeatureService._fulfill_params_from_enterprise(features, is_authenticated=False)
license_model = FeatureService.get_license()
assert features.license.seats.enabled is False
assert features.license.seats.limit == 0
assert features.license.seats.size == 0
assert license_model.status == LicenseStatus.NONE
assert license_model.seats.enabled is False
assert license_model.seats.is_available() is True
@@ -1,5 +1,6 @@
import pytest
from enums.deployment_edition import DeploymentEdition
from services.feature_service import FeatureService, SystemFeatureModel
@@ -18,7 +19,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()
system_features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY)
FeatureService._fulfill_system_params_from_env(system_features)
assert system_features.webapp_auth.allow_public_access is expected
@@ -10,6 +10,7 @@ import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session
from enums.deployment_edition import DeploymentEdition
from models.model import AccountTrialAppRecord, App, AppMode, TrialApp
from services import recommended_app_service as service_module
from services.feature_service import SystemFeatureModel
@@ -298,7 +299,10 @@ class TestRecommendedAppServiceGetDetail:
sqlite_session: Session,
) -> None:
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
mock_feature_service.get_system_features.return_value = SystemFeatureModel(enable_trial_app=False)
mock_feature_service.get_system_features.return_value = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=False,
)
cases: list[tuple[str, RecommendedAppPayload]] = [
(
"complex-app",
@@ -337,7 +341,10 @@ class TestRecommendedAppServiceGetDetail:
mock_feature_service: MagicMock,
sqlite_session: Session,
) -> None:
mock_feature_service.get_system_features.return_value = SystemFeatureModel(enable_trial_app=False)
mock_feature_service.get_system_features.return_value = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
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}")
@@ -367,7 +374,10 @@ class TestRecommendedAppServiceGetLearnDifyApps:
sqlite_session: Session,
) -> None:
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
mock_feature_service.get_system_features.return_value = SystemFeatureModel(enable_trial_app=False)
mock_feature_service.get_system_features.return_value = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=False,
)
expected_app = RecommendedAppPayload(app_id="app-1", category="Workflow")
mock_instance = MagicMock()
mock_instance.get_learn_dify_apps.return_value = {
@@ -402,7 +412,12 @@ class TestRecommendedAppServiceGetLearnDifyApps:
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(return_value=SystemFeatureModel(enable_trial_app=True)),
MagicMock(
return_value=SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=True,
)
),
)
can_trial_mock = MagicMock(return_value=True)
monkeypatch.setattr(RecommendedAppService, "_can_trial_app", can_trial_mock)
@@ -425,7 +440,12 @@ class TestRecommendedAppServiceTrialFeatures:
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(return_value=SystemFeatureModel(enable_trial_app=False)),
MagicMock(
return_value=SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=False,
)
),
)
result = RecommendedAppService.get_recommended_apps_and_categories("en-US", session=sqlite_session)
@@ -456,7 +476,12 @@ class TestRecommendedAppServiceTrialFeatures:
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(return_value=SystemFeatureModel(enable_trial_app=True)),
MagicMock(
return_value=SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=True,
)
),
)
result = RecommendedAppService.get_recommended_apps_and_categories("ja-JP", session=sqlite_session)
@@ -492,7 +517,12 @@ class TestRecommendedAppServiceTrialFeatures:
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(return_value=SystemFeatureModel(enable_trial_app=True)),
MagicMock(
return_value=SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=True,
)
),
)
result = RecommendedAppService.get_recommend_app_detail(app_id, session=sqlite_session)
+14 -2
View File
@@ -16,11 +16,21 @@ 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
# Learn app feature toggle
ALLOW_REGISTER=false
ALLOW_CREATE_WORKSPACE=false
ENABLE_CHANGE_EMAIL=true
ENABLE_TRIAL_APP=false
ENABLE_EXPLORE_BANNER=false
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
@@ -90,6 +100,8 @@ 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,7 +15,6 @@ 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
+1 -1
View File
@@ -660,7 +660,7 @@ export const lintConfig = {
'error',
{
allowConstantExport: true,
allowExportNames: [],
allowExportNames: ['viewport'],
},
],
},
-5
View File
@@ -5951,11 +5951,6 @@
"count": 1
}
},
"web/app/layout.tsx": {
"react/only-export-components": {
"count": 1
}
},
"web/app/reset-password/check-code/page.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@@ -1,7 +1,31 @@
// This file is auto-generated by @hey-api/openapi-ts
import { oc } from '@orpc/contract'
import { zGetSystemFeaturesResponse } from './zod.gen'
import { zGetSystemFeaturesLicenseResponse, zGetSystemFeaturesResponse } from './zod.gen'
/**
* Get full license detail (status, expiry, workspace/seat usage)
*
* Get license status and usage detail
* Authenticated counterpart to the license *status* exposed on the public
* system-features endpoint.
*/
export const get = oc
.route({
description:
'Get license status and usage detail\nAuthenticated counterpart to the license *status* exposed on the public\nsystem-features endpoint.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getSystemFeaturesLicense',
path: '/system-features/license',
summary: 'Get full license detail (status, expiry, workspace/seat usage)',
tags: ['console'],
})
.output(zGetSystemFeaturesLicenseResponse)
export const license = {
get,
}
/**
* Get system-wide feature configuration
@@ -12,12 +36,13 @@ import { zGetSystemFeaturesResponse } from './zod.gen'
*
* Authentication would create circular dependency (can't login without dashboard loading).
*
* Only non-sensitive configuration data should be returned by this endpoint.
* Only non-sensitive configuration data should be returned by this endpoint. Authenticated
* license detail is served separately by SystemFeatureLicenseApi.
*/
export const get = oc
export const get2 = oc
.route({
description:
"Get system-wide feature configuration\nNOTE: This endpoint is unauthenticated by design, as it provides system features\ndata required for dashboard initialization.\n\nAuthentication would create circular dependency (can't login without dashboard loading).\n\nOnly non-sensitive configuration data should be returned by this endpoint.",
"Get system-wide feature configuration\nNOTE: This endpoint is unauthenticated by design, as it provides system features\ndata required for dashboard initialization.\n\nAuthentication would create circular dependency (can't login without dashboard loading).\n\nOnly non-sensitive configuration data should be returned by this endpoint. Authenticated\nlicense detail is served separately by SystemFeatureLicenseApi.",
inputStructure: 'detailed',
method: 'GET',
operationId: 'getSystemFeatures',
@@ -28,7 +53,8 @@ export const get = oc
.output(zGetSystemFeaturesResponse)
export const systemFeatures = {
get,
get: get2,
license,
}
export const contract = {
@@ -6,6 +6,7 @@ export type ClientOptions = {
export type SystemFeatureModel = {
branding: BrandingModel
deployment_edition: DeploymentEdition
enable_app_deploy: boolean
enable_change_email: boolean
enable_collaboration_mode: boolean
@@ -22,7 +23,7 @@ export type SystemFeatureModel = {
is_allow_register: boolean
is_email_setup: boolean
knowledge_fs_enabled: boolean
license: LicenseModel
license: LicenseStatusModel
max_plugin_package_size: number
plugin_installation_permission: PluginInstallationPermissionModel
plugin_manager: PluginManagerModel
@@ -32,6 +33,13 @@ export type SystemFeatureModel = {
webapp_auth: WebAppAuthModel
}
export type LicenseModel = {
expired_at: string
seats: LicenseLimitationModel
status: LicenseStatus
workspaces: LicenseLimitationModel
}
export type BrandingModel = {
application_title: string
enabled: boolean
@@ -40,11 +48,10 @@ export type BrandingModel = {
workspace_logo: string
}
export type LicenseModel = {
expired_at: string
seats: LicenseLimitationModel
export type DeploymentEdition = 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE'
export type LicenseStatusModel = {
status: LicenseStatus
workspaces: LicenseLimitationModel
}
export type PluginInstallationPermissionModel = {
@@ -95,3 +102,17 @@ export type GetSystemFeaturesResponses = {
}
export type GetSystemFeaturesResponse = GetSystemFeaturesResponses[keyof GetSystemFeaturesResponses]
export type GetSystemFeaturesLicenseData = {
body?: never
path?: never
query?: never
url: '/system-features/license'
}
export type GetSystemFeaturesLicenseResponses = {
200: LicenseModel
}
export type GetSystemFeaturesLicenseResponse =
GetSystemFeaturesLicenseResponses[keyof GetSystemFeaturesLicenseResponses]
@@ -13,6 +13,13 @@ export const zBrandingModel = z.object({
workspace_logo: z.string().default(''),
})
/**
* DeploymentEdition
*
* Enum representing the deployment edition of the platform.
*/
export const zDeploymentEdition = z.enum(['CLOUD', 'COMMUNITY', 'ENTERPRISE'])
/**
* PluginManagerModel
*/
@@ -56,6 +63,13 @@ export const zLicenseModel = z.object({
}),
})
/**
* LicenseStatusModel
*/
export const zLicenseStatusModel = z.object({
status: zLicenseStatus.default('none'),
})
/**
* PluginInstallationScope
*/
@@ -104,6 +118,7 @@ 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),
@@ -120,20 +135,7 @@ export const zSystemFeatureModel = z.object({
is_allow_register: z.boolean().default(false),
is_email_setup: z.boolean().default(false),
knowledge_fs_enabled: z.boolean().default(false),
license: zLicenseModel.default({
expired_at: '',
seats: {
enabled: false,
limit: 0,
size: 0,
},
status: 'none',
workspaces: {
enabled: false,
limit: 0,
size: 0,
},
}),
license: zLicenseStatusModel.default({ status: 'none' }),
max_plugin_package_size: z.int().default(15728640),
plugin_installation_permission: zPluginInstallationPermissionModel.default({
plugin_installation_scope: 'all',
@@ -157,3 +159,8 @@ export const zSystemFeatureModel = z.object({
* Success
*/
export const zGetSystemFeaturesResponse = zSystemFeatureModel
/**
* Success
*/
export const zGetSystemFeaturesLicenseResponse = zLicenseModel
@@ -118,6 +118,8 @@ export type ConversationRenamePayload = (
name?: string | null
}
export type DeploymentEdition = 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE'
export type EmailCodeLoginSendPayload = {
email: string
language?: string | null
@@ -300,21 +302,12 @@ export type JsonValueType = unknown
export type JsonValue2 = unknown
export type LicenseLimitationModel = {
enabled: boolean
limit: number
size: number
}
export type LicenseModel = {
expired_at: string
seats: LicenseLimitationModel
status: LicenseStatus
workspaces: LicenseLimitationModel
}
export type LicenseStatus = 'active' | 'expired' | 'expiring' | 'inactive' | 'lost' | 'none'
export type LicenseStatusModel = {
status: LicenseStatus
}
export type LoginPayload = {
email: string
password: string
@@ -510,6 +503,7 @@ export type SuggestedQuestionsResponse = {
export type SystemFeatureModel = {
branding: BrandingModel
deployment_edition: DeploymentEdition
enable_app_deploy: boolean
enable_change_email: boolean
enable_collaboration_mode: boolean
@@ -526,7 +520,7 @@ export type SystemFeatureModel = {
is_allow_register: boolean
is_email_setup: boolean
knowledge_fs_enabled: boolean
license: LicenseModel
license: LicenseStatusModel
max_plugin_package_size: number
plugin_installation_permission: PluginInstallationPermissionModel
plugin_manager: PluginManagerModel
+11 -40
View File
@@ -132,6 +132,13 @@ export const zConversationRenamePayload = z.intersection(
}),
)
/**
* DeploymentEdition
*
* Enum representing the deployment edition of the platform.
*/
export const zDeploymentEdition = z.enum(['CLOUD', 'COMMUNITY', 'ENTERPRISE'])
/**
* EmailCodeLoginSendPayload
*/
@@ -340,40 +347,16 @@ export const zHumanInputFormSubmitPayload = z.object({
inputs: z.record(z.string(), zJsonValue2),
})
/**
* LicenseLimitationModel
*
* - enabled: whether this limit is enforced
* - size: current usage count
* - limit: maximum allowed count; 0 means unlimited
*/
export const zLicenseLimitationModel = z.object({
enabled: z.boolean().default(false),
limit: z.int().default(0),
size: z.int().default(0),
})
/**
* LicenseStatus
*/
export const zLicenseStatus = z.enum(['active', 'expired', 'expiring', 'inactive', 'lost', 'none'])
/**
* LicenseModel
* LicenseStatusModel
*/
export const zLicenseModel = z.object({
expired_at: z.string().default(''),
seats: zLicenseLimitationModel.default({
enabled: false,
limit: 0,
size: 0,
}),
export const zLicenseStatusModel = z.object({
status: zLicenseStatus.default('none'),
workspaces: zLicenseLimitationModel.default({
enabled: false,
limit: 0,
size: 0,
}),
})
/**
@@ -782,6 +765,7 @@ 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),
@@ -798,20 +782,7 @@ export const zSystemFeatureModel = z.object({
is_allow_register: z.boolean().default(false),
is_email_setup: z.boolean().default(false),
knowledge_fs_enabled: z.boolean().default(false),
license: zLicenseModel.default({
expired_at: '',
seats: {
enabled: false,
limit: 0,
size: 0,
},
status: 'none',
workspaces: {
enabled: false,
limit: 0,
size: 0,
},
}),
license: zLicenseStatusModel.default({ status: 'none' }),
max_plugin_package_size: z.int().default(15728640),
plugin_installation_permission: zPluginInstallationPermissionModel.default({
plugin_installation_scope: 'all',
-21
View File
@@ -1,9 +1,5 @@
# For production release, change this to PRODUCTION
NEXT_PUBLIC_DEPLOY_ENV=DEVELOPMENT
# The deployment edition, SELF_HOSTED
NEXT_PUBLIC_EDITION=SELF_HOSTED
# Whether a self-hosted deployment runs Enterprise Edition
NEXT_PUBLIC_ENTERPRISE_ENABLED=false
# The base path for the application
NEXT_PUBLIC_BASE_PATH=
# Server-only console API origin for server-side requests.
@@ -114,20 +110,3 @@ 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
-1
View File
@@ -49,7 +49,6 @@ 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,3 +1,5 @@
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'
@@ -14,21 +16,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 { render } from '@/test/console/render'
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 })
}
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,3 +1,5 @@
import type { RenderOptions } from '@testing-library/react'
import type { ReactElement } from 'react'
/**
* Integration test: Education Verification Flow
*
@@ -14,7 +16,15 @@ 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 { render } from '@/test/console/render'
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 })
}
// ─── Mock state ──────────────────────────────────────────────────────────────
let mockProviderCtx: Record<string, unknown> = {}
@@ -25,14 +35,6 @@ 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,11 +7,15 @@
* Covers URL param reading, cookie persistence, API bind on mount,
* cookie cleanup after successful bind, and error handling for 400 status.
*/
import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react'
import { act, cleanup, 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()
@@ -39,7 +43,6 @@ 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,
@@ -288,7 +291,7 @@ describe('Partner Stack Flow', () => {
// ─── 4. PartnerStack Component Mount ────────────────────────────────────
describe('PartnerStack component mount behavior', () => {
it('should call saveOrUpdate and bind on mount when IS_CLOUD_EDITION is true', async () => {
it('should call saveOrUpdate and bind on mount', async () => {
mockSearchParams = new URLSearchParams({
ps_partner_key: 'mount-partner',
ps_xid: 'mount-click',
@@ -299,19 +302,14 @@ 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',
})
})
// 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 () => {
const { default: PartnerStack } = await import('@/app/components/billing/partner-stack')
-28
View File
@@ -1,6 +1,5 @@
describe('env runtime transport', () => {
const originalAgentV2Env = process.env.NEXT_PUBLIC_ENABLE_AGENT_V2
const originalRbacEnv = process.env.NEXT_PUBLIC_RBAC_ENABLED
beforeEach(() => {
vi.clearAllMocks()
@@ -8,17 +7,12 @@ 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 () => {
@@ -29,14 +23,6 @@ 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'
@@ -51,18 +37,4 @@ 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)
})
})
@@ -1,12 +1,12 @@
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(() => ({
queryClient: undefined as QueryClient | undefined,
rootQueryClient: undefined as QueryClient | undefined,
profileQueryFn: vi.fn(),
systemFeaturesQueryFn: vi.fn(),
workspaceQueryFn: vi.fn(),
workspaceQueryOptions: vi.fn(),
getServerConsoleClientContext: vi.fn(),
@@ -18,9 +18,14 @@ const mocks = vi.hoisted(() => ({
basePath: '',
}))
vi.mock('@/context/query-client-server', () => ({
getQueryClientServer: () => mocks.queryClient,
}))
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('@/next/headers', () => ({
headers: () => mocks.headers(),
@@ -58,19 +63,11 @@ 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.queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
mocks.rootQueryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
mocks.headers.mockResolvedValue(
new Headers({
'x-dify-pathname': '/apps',
@@ -94,7 +91,6 @@ describe('CommonLayoutHydrationBoundary', () => {
currentEnv: 'DEVELOPMENT',
},
})
mocks.systemFeaturesQueryFn.mockResolvedValue({ branding: { enabled: false } })
mocks.workspaceQueryFn.mockResolvedValue({ id: 'workspace-id', name: 'Workspace' })
mocks.getServerConsoleClientContext.mockResolvedValue({
cookie: 'session=abc',
@@ -107,7 +103,7 @@ describe('CommonLayoutHydrationBoundary', () => {
})
})
it('should prefetch common layout queries and render children', async () => {
it('should prefetch common layout queries', async () => {
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
const element = await CommonLayoutHydrationBoundary({
@@ -121,7 +117,6 @@ describe('CommonLayoutHydrationBoundary', () => {
)
expect(screen.getByText('Common shell')).toBeInTheDocument()
expect(mocks.profileQueryFn).toHaveBeenCalledTimes(1)
expect(mocks.systemFeaturesQueryFn).toHaveBeenCalledTimes(1)
expect(mocks.getServerConsoleClientContext).toHaveBeenCalledTimes(1)
expect(mocks.workspaceQueryOptions).toHaveBeenCalledWith({
context: {
@@ -133,6 +128,25 @@ 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(
@@ -188,7 +202,6 @@ describe('CommonLayoutHydrationBoundary', () => {
)
expect(screen.getByText('Common shell')).toBeInTheDocument()
expect(mocks.profileQueryFn).not.toHaveBeenCalled()
expect(mocks.systemFeaturesQueryFn).not.toHaveBeenCalled()
expect(mocks.workspaceQueryFn).not.toHaveBeenCalled()
})
})
@@ -2,10 +2,9 @@ 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 { ConsoleBootstrapGate } from '../console-bootstrap-gate'
import { ProfileBootstrapGate } from '../profile-bootstrap-gate'
const profileQueryKey = ['console', 'account', 'profile', 'get']
const systemFeaturesQueryKey = ['console', 'system-features', 'get']
type Deferred<T> = {
promise: Promise<T>
@@ -13,7 +12,7 @@ type Deferred<T> = {
reject: (reason?: unknown) => void
}
const createDeferred = <T,>(): Deferred<T> => {
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((nextResolve, nextReject) => {
@@ -26,7 +25,6 @@ const createDeferred = <T,>(): Deferred<T> => {
const mocks = vi.hoisted(() => ({
profileQuery: undefined as Deferred<{ id: string }> | undefined,
systemFeaturesQuery: undefined as Deferred<{ branding: { enabled: boolean } }> | undefined,
}))
vi.mock('@/features/account-profile/client', () => ({
@@ -36,13 +34,6 @@ vi.mock('@/features/account-profile/client', () => ({
}),
}))
vi.mock('@/features/system-features/client', () => ({
systemFeaturesQueryOptions: () => ({
queryKey: systemFeaturesQueryKey,
queryFn: () => mocks.systemFeaturesQuery!.promise,
}),
}))
function createQueryClient() {
return new QueryClient({
defaultOptions: {
@@ -54,20 +45,19 @@ function createQueryClient() {
function renderGate(children: ReactNode, queryClient = createQueryClient()) {
render(
<QueryClientProvider client={queryClient}>
<ConsoleBootstrapGate>{children}</ConsoleBootstrapGate>
<ProfileBootstrapGate>{children}</ProfileBootstrapGate>
</QueryClientProvider>,
)
return queryClient
}
describe('ConsoleBootstrapGate', () => {
describe('ProfileBootstrapGate', () => {
beforeEach(() => {
mocks.profileQuery = createDeferred()
mocks.systemFeaturesQuery = createDeferred()
})
it('waits for profile and system features before mounting atom consumers', async () => {
it('waits for the profile before mounting atom consumers', async () => {
renderGate(<div>Console shell</div>)
expect(screen.queryByText('Console shell')).not.toBeInTheDocument()
@@ -75,11 +65,6 @@ describe('ConsoleBootstrapGate', () => {
await act(async () => {
mocks.profileQuery!.resolve({ id: 'user-1' })
})
expect(screen.queryByText('Console shell')).not.toBeInTheDocument()
await act(async () => {
mocks.systemFeaturesQuery!.resolve({ branding: { enabled: false } })
})
expect(await screen.findByText('Console shell')).toBeInTheDocument()
})
@@ -87,11 +72,6 @@ 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,
{ branding: { enabled: false } },
{ updatedAt: 1 },
)
renderGate(<div>Console shell</div>, queryClient)
@@ -102,7 +82,6 @@ describe('ConsoleBootstrapGate', () => {
await act(async () => {
mocks.profileQuery!.reject(new Error('profile refetch failed'))
mocks.systemFeaturesQuery!.resolve({ branding: { enabled: false } })
})
await waitFor(() => {
@@ -1,5 +1,6 @@
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
import { screen } from '@testing-library/react'
import { renderWithAccountProfile as render } from '@/test/console/account-profile'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { AppACLPermission } from '@/utils/permission'
import ChartView from '../chart-view'
@@ -13,6 +14,7 @@ const testState = vi.hoisted(() => ({
currentUserId: 'user-1',
workspacePermissionKeys: [] as string[],
chartRenderSpy: vi.fn(),
conversationPeriodSpy: vi.fn(),
}))
vi.mock('@/context/workspace-state', async () => {
@@ -46,8 +48,9 @@ vi.mock('@/app/components/app/overview/app-chart', () => ({
testState.chartRenderSpy('avg-user-interactions')
return <div>avg user interactions chart</div>
},
ConversationsChart: () => {
ConversationsChart: ({ period }: { period: PeriodParams }) => {
testState.chartRenderSpy('conversations')
testState.conversationPeriodSpy(period)
return <div>conversations chart</div>
},
CostChart: () => {
@@ -1,6 +1,8 @@
'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'
@@ -23,10 +25,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'
@@ -51,7 +53,28 @@ 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)
@@ -67,8 +90,8 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) {
)
const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow'
const isWorkflow = appDetail?.mode === 'workflow'
const [period, setPeriod] = useState<PeriodParams>(
IS_CLOUD_EDITION
const [period, setPeriod] = useState<PeriodParams>(() =>
isCloudEdition
? {
name: t(($) => $['filter.period.today'], { ns: 'appLog' }),
query: {
@@ -112,13 +135,14 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) {
</div>
</div>
<div className="mt-1 flex h-10 items-center justify-between pr-10 pl-6">
{IS_CLOUD_EDITION ? (
{isCloudEdition && (
<TimeRangePicker
ranges={TIME_PERIOD_MAPPING}
onSelect={setPeriod}
queryDateFormat={queryDateFormat}
/>
) : (
)}
{isNonCloudEdition && (
<LongTimeRangePicker
periodMapping={LONG_TIME_PERIOD_MAPPING}
onSelect={setPeriod}
@@ -1,14 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { useSuspenseQueries } from '@tanstack/react-query'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
export function ConsoleBootstrapGate({ children }: { children: ReactNode }) {
useSuspenseQueries({
queries: [userProfileQueryOptions(), systemFeaturesQueryOptions()],
})
return children
}
@@ -43,14 +43,6 @@ 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,6 +2,7 @@
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'
@@ -13,8 +14,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'
@@ -60,7 +61,10 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const pathname = usePathname()
const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom)
const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom)
const isRbacEnabled = useAtomValue(datasetRbacEnabledAtom)
const { data: isRbacEnabled } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: ({ rbac_enabled }) => rbac_enabled,
})
const currentUserId = useAtomValue(userProfileIdAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
@@ -1,4 +1,3 @@
import { KnowledgeRouteGuard } from '@/features/new-rag/knowledge-route-guard'
import { KnowledgeSpaceShell } from '@/features/new-rag/knowledge-space-shell'
export default async function Layout({
@@ -10,9 +9,5 @@ export default async function Layout({
}) {
const { knowledgeSpaceId } = await params
return (
<KnowledgeRouteGuard>
<KnowledgeSpaceShell knowledgeSpaceId={knowledgeSpaceId}>{children}</KnowledgeSpaceShell>
</KnowledgeRouteGuard>
)
return <KnowledgeSpaceShell knowledgeSpaceId={knowledgeSpaceId}>{children}</KnowledgeSpaceShell>
}
@@ -0,0 +1,71 @@
import type { ReactElement, ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
const mocks = vi.hoisted(() => ({
ensureQueryData: vi.fn(),
redirect: vi.fn((_href: string) => {
throw new Error('NEXT_REDIRECT')
}),
systemFeaturesQueryOptions: { queryKey: ['console', 'system-features'] },
}))
vi.mock('@/context/query-client-server', () => ({
getQueryClientServer: () => ({
ensureQueryData: mocks.ensureQueryData,
}),
}))
vi.mock('@/service/server', () => ({
serverConsoleQuery: {
systemFeatures: {
get: {
queryOptions: () => mocks.systemFeaturesQueryOptions,
},
},
},
}))
vi.mock('@/next/navigation', () => ({
redirect: (href: string) => mocks.redirect(href),
}))
async function renderLayout(children: ReactNode) {
const { default: Layout } = await import('../layout')
const element = await Layout({ children })
render(element as ReactElement)
}
describe('NewKnowledgeLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.ensureQueryData.mockResolvedValue({ knowledge_fs_enabled: true })
})
it('renders new knowledge routes when KnowledgeFS is enabled', async () => {
await renderLayout(<div>New knowledge content</div>)
expect(mocks.ensureQueryData).toHaveBeenCalledWith(mocks.systemFeaturesQueryOptions)
expect(screen.getByText('New knowledge content')).toBeInTheDocument()
expect(mocks.redirect).not.toHaveBeenCalled()
})
it('redirects to datasets when KnowledgeFS is disabled', async () => {
mocks.ensureQueryData.mockResolvedValue({ knowledge_fs_enabled: false })
const { default: Layout } = await import('../layout')
await expect(Layout({ children: <div>New knowledge content</div> })).rejects.toThrow(
'NEXT_REDIRECT',
)
expect(mocks.redirect).toHaveBeenCalledWith('/datasets')
})
it('preserves System Features failures', async () => {
const error = new Error('System Features unavailable')
mocks.ensureQueryData.mockRejectedValue(error)
const { default: Layout } = await import('../layout')
await expect(Layout({ children: <div>New knowledge content</div> })).rejects.toBe(error)
expect(mocks.redirect).not.toHaveBeenCalled()
})
})
@@ -1,10 +1,5 @@
import { CreateKnowledgePage } from '@/features/new-rag/create-knowledge-page'
import { KnowledgeRouteGuard } from '@/features/new-rag/knowledge-route-guard'
export default function Page() {
return (
<KnowledgeRouteGuard>
<CreateKnowledgePage />
</KnowledgeRouteGuard>
)
return <CreateKnowledgePage />
}
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react'
import { getQueryClientServer } from '@/context/query-client-server'
import { redirect } from '@/next/navigation'
import { serverConsoleQuery } from '@/service/server'
export default async function Layout({ children }: { children: ReactNode }) {
const systemFeatures = await getQueryClientServer().ensureQueryData(
serverConsoleQuery.systemFeatures.get.queryOptions(),
)
if (!systemFeatures.knowledge_fs_enabled) redirect('/datasets')
return children
}
@@ -15,8 +15,14 @@ vi.mock('@/context/query-client-server', () => ({
}),
}))
vi.mock('@/features/system-features/server', () => ({
serverSystemFeaturesQueryOptions: () => mocks.systemFeaturesQueryOptions,
vi.mock('@/service/server', () => ({
serverConsoleQuery: {
systemFeatures: {
get: {
queryOptions: () => mocks.systemFeaturesQueryOptions,
},
},
},
}))
vi.mock('@/features/deployments/deploy-drawer', () => ({
@@ -1,12 +1,12 @@
import type { ReactNode } from 'react'
import { getQueryClientServer } from '@/context/query-client-server'
import { DeployDrawer } from '@/features/deployments/deploy-drawer'
import { serverSystemFeaturesQueryOptions } from '@/features/system-features/server'
import { notFound } from '@/next/navigation'
import { serverConsoleQuery } from '@/service/server'
export default async function DeploymentsLayout({ children }: { children: ReactNode }) {
const systemFeatures = await getQueryClientServer().ensureQueryData(
serverSystemFeaturesQueryOptions(),
serverConsoleQuery.systemFeatures.get.queryOptions(),
)
if (!systemFeatures.enable_app_deploy) notFound()
+10 -1
View File
@@ -1,6 +1,7 @@
'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'
@@ -12,6 +13,7 @@ type Props = Readonly<{
export default function CommonLayoutError({ error, unstable_retry }: Props) {
const { t } = useTranslation('common')
const { reset } = useQueryErrorResetBoundary()
console.error(error)
@@ -26,7 +28,14 @@ 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={() => unstable_retry()}>
<Button
size="small"
variant="secondary"
onClick={() => {
reset()
unstable_retry()
}}
>
{t(($) => $['errorBoundary.tryAgain'])}
</Button>
</div>
@@ -1,8 +1,7 @@
import type { ReactNode } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { getQueryClientServer } from '@/context/query-client-server'
import { makeQueryClient } 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 {
@@ -56,7 +55,7 @@ const handleProfileError = async (error: unknown) => {
}
export async function CommonLayoutHydrationBoundary({ children }: { children: ReactNode }) {
const queryClient = getQueryClientServer()
const queryClient = makeQueryClient()
const accountProfileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH)
if (accountProfileUrl) {
@@ -65,7 +64,6 @@ export async function CommonLayoutHydrationBoundary({ children }: { children: Re
await Promise.all([
queryClient.fetchQuery(serverUserProfileQueryOptions()),
queryClient.prefetchQuery(serverSystemFeaturesQueryOptions()),
queryClient.prefetchQuery(
serverConsoleQuery.workspaces.current.post.queryOptions({
context,
@@ -0,0 +1,11 @@
'use client'
import type { ReactNode } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { userProfileQueryOptions } from '@/features/account-profile/client'
export function ProfileBootstrapGate({ children }: { children: ReactNode }) {
useSuspenseQuery(userProfileQueryOptions())
return children
}
+3 -3
View File
@@ -4,9 +4,9 @@ import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import { ConsoleBootstrapGate } from './console-bootstrap-gate'
import { ExternalServiceSync } from './external-service-sync'
import { CommonLayoutHydrationBoundary } from './hydration-boundary'
import { ProfileBootstrapGate } from './profile-bootstrap-gate'
export async function ConsoleRuntimeProviders({ children }: { children: ReactNode }) {
return (
@@ -14,10 +14,10 @@ export async function ConsoleRuntimeProviders({ children }: { children: ReactNod
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<ConsoleBootstrapGate>
<ProfileBootstrapGate>
<ExternalServiceSync />
{children}
</ConsoleBootstrapGate>
</ProfileBootstrapGate>
</CommonLayoutHydrationBoundary>
</>
)
@@ -6,7 +6,6 @@ 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'
@@ -19,6 +18,9 @@ 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)
@@ -236,7 +238,7 @@ const NormalForm = () => {
{t(($) => $.pp, { ns: 'login' })}
</Link>
</div>
{IS_CE_EDITION && (
{isNonCloudEdition && (
<div className="w-hull mt-2 block system-xs-regular text-text-tertiary">
{t(($) => $.goToInit, { ns: 'login' })}
&nbsp;
+39
View File
@@ -0,0 +1,39 @@
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)
})
})
+71
View File
@@ -0,0 +1,71 @@
import { QueryClient } from '@tanstack/react-query'
let queryClient: QueryClient
const mocks = vi.hoisted(() => ({
getSystemFeatures: vi.fn(),
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('@/service/server', () => ({
serverConsoleQuery: {
systemFeatures: {
get: {
queryOptions: () => ({
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,
}))
describe('Root layout System Features bootstrap', () => {
beforeEach(() => {
vi.clearAllMocks()
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
})
it('caches the resolved System Features for dehydration', 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(queryClient.getQueryData(['console', 'system-features'])).toEqual({
deployment_edition: 'CLOUD',
})
})
it('renders the client recovery path when the server prefetch fails', async () => {
mocks.getSystemFeatures.mockRejectedValue(new Error('system features unavailable'))
const { default: RootLayout } = await import('../layout')
await expect(RootLayout({ children: <div>App</div> })).resolves.toBeDefined()
expect(queryClient.getQueryData(['console', 'system-features'])).toBeUndefined()
})
})
@@ -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 { IS_CE_EDITION, validPassword } from '@/config'
import { 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"
/>
)}
{!IS_CE_EDITION && (
{systemFeatures.deployment_edition === 'CLOUD' && (
<Button
className="mt-2 text-components-button-destructive-secondary-text"
onClick={() => setShowDeleteAccountModal(true)}
+2 -10
View File
@@ -4,16 +4,12 @@ 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', () => ({}))
@@ -52,7 +48,6 @@ 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 () => {
@@ -244,8 +239,7 @@ describe('auth refresh route', () => {
expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F')
})
it('should keep a Cloud staging fallback on the current deployment after refresh', async () => {
mocks.isCloudEdition = true
it('should keep a staging fallback on the current deployment after refresh', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
const { GET } = await import('../route')
@@ -260,8 +254,7 @@ describe('auth refresh route', () => {
expect(response.headers.get('location')).toBe('/')
})
it('should carry the current Cloud deployment fallback through signin when refresh fails', async () => {
mocks.isCloudEdition = true
it('should carry the current deployment fallback through signin when refresh fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
const { GET } = await import('../route')
@@ -277,7 +270,6 @@ 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,17 +6,10 @@ 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(),
}))
@@ -43,7 +36,6 @@ describe('ExternalAttributionRecorder', () => {
beforeEach(() => {
vi.clearAllMocks()
Cookies.remove('utm_info')
mockConfig.IS_CLOUD_EDITION = true
setAnalyticsConsent('granted')
setSearchParams()
})
@@ -150,14 +142,4 @@ 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,7 +35,15 @@ vi.mock('@/service/access-control', () => ({
vi.mock('@/service/client', () => ({
consoleQuery: {
systemFeatures: { get: { queryKey: () => ['system-features'] } },
systemFeatures: {
get: {
queryKey: () => ['system-features'],
queryOptions: (options: Record<string, unknown> = {}) => ({
queryKey: ['system-features'],
...options,
}),
},
},
enterprise: {
webAppAuth: {
updateWebAppWhitelistSubjects: {
@@ -1,7 +1,8 @@
/* oxlint-disable typescript/no-explicit-any */
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, 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,10 +3,11 @@ 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, render, screen, waitFor, within } from '@testing-library/react'
import { act, fireEvent, 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,10 +1,11 @@
import type { InputVar } from '@/app/components/workflow/types'
import type { App, AppSSO } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, 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'
@@ -8,7 +8,6 @@ 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,
@@ -18,6 +17,7 @@ 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, defaultSystemFeatures)
queryClient.setQueryData(systemFeaturesQueryOptions().queryKey, createSystemFeaturesFixture())
return render(
<QueryClientProvider client={queryClient}>
@@ -1,22 +1,18 @@
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 as render } from '@/test/console/query-data'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
import AppCard from '../index'
vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn() }))
const mockConfig = vi.hoisted(() => ({ isCloudEdition: true }))
vi.mock('@/config', async (importOriginal) => ({
...(await importOriginal<typeof import('@/config')>()),
get IS_CLOUD_EDITION() {
return mockConfig.isCloudEdition
},
}))
const render = (ui: ReactElement) =>
renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' } })
const app: App = {
can_trial: true,
@@ -47,7 +43,6 @@ const app: App = {
describe('AppCard', () => {
beforeEach(() => {
mockConfig.isCloudEdition = true
vi.clearAllMocks()
})
@@ -4,13 +4,14 @@ 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 = {
@@ -21,8 +22,12 @@ 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 = IS_CLOUD_EDITION
const canViewApp = deploymentEdition === 'CLOUD'
const setShowTryAppPanel = useContextSelector(AppListContext, (ctx) => ctx.setShowTryAppPanel)
const handleShowTryAppPanel = useCallback(() => {
trackEvent('preview_template', {
@@ -3,27 +3,26 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import InSiteMessageNotification from '../notification'
const { mockConfig, mockNotification, mockNotificationDismiss } = vi.hoisted(() => ({
mockConfig: {
isCloudEdition: true,
const { mockEdition, mockNotification, mockNotificationDismiss } = vi.hoisted(() => ({
mockEdition: {
value: 'CLOUD' as 'COMMUNITY' | 'ENTERPRISE' | 'CLOUD' | null,
},
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>) => ({
@@ -56,6 +55,9 @@ const createWrapper = () => {
},
},
})
queryClient.setQueryData(['console', 'systemFeatures', 'get'], {
deployment_edition: mockEdition.value,
})
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
@@ -67,7 +69,7 @@ const createWrapper = () => {
describe('InSiteMessageNotification', () => {
beforeEach(() => {
vi.clearAllMocks()
mockConfig.isCloudEdition = true
mockEdition.value = 'CLOUD'
vi.stubGlobal('open', vi.fn())
})
@@ -78,7 +80,7 @@ describe('InSiteMessageNotification', () => {
// Validate query gating and empty state rendering.
describe('Rendering', () => {
it('should render null and skip query when not cloud edition', async () => {
mockConfig.isCloudEdition = false
mockEdition.value = 'COMMUNITY'
const Wrapper = createWrapper()
const { container } = render(<InSiteMessageNotification />, { wrapper: Wrapper })
@@ -1,9 +1,9 @@
'use client'
import type { InSiteMessageActionItem } from './index'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { IS_CLOUD_EDITION } from '@/config'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { consoleQuery } from '@/service/client'
import InSiteMessage from './index'
@@ -56,20 +56,25 @@ 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: IS_CLOUD_EDITION,
enabled: isCloudEdition,
}),
)
const notification = data?.notifications?.[0]
const parsedBody = notification ? parseNotificationBody(notification.body) : null
if (!IS_CLOUD_EDITION || !notification || !notification.notification_id) return null
if (!isCloudEdition || !notification || !notification.notification_id) return null
const notificationId = notification.notification_id
const fallbackActions: InSiteMessageActionItem[] = [
@@ -5,17 +5,10 @@ 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')
@@ -57,6 +50,12 @@ 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()
@@ -69,7 +68,7 @@ describe('ArchivedLogsNotice', () => {
})
it('should show notice for paid workspace managers', () => {
render(<ArchivedLogsNotice />)
renderNotice()
expect(screen.getByText('appLog.archives.notice.description')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'appLog.archives.notice.action' }))
@@ -81,7 +80,7 @@ describe('ArchivedLogsNotice', () => {
it('should not show notice for sandbox workspaces', () => {
mockProviderPlan(Plan.sandbox)
render(<ArchivedLogsNotice />)
renderNotice()
expect(screen.queryByText('appLog.archives.notice.description')).not.toBeInTheDocument()
})
@@ -1,16 +1,21 @@
'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(
@@ -18,7 +23,7 @@ export function ArchivedLogsNotice() {
)
if (
!IS_CLOUD_EDITION ||
deploymentEdition !== 'CLOUD' ||
!isCurrentWorkspaceManager ||
!enableBilling ||
plan.type === Plan.sandbox
@@ -313,6 +313,7 @@ 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,10 +5,9 @@ import {
interactions,
mockUseModalContext,
scenarios,
setDeploymentEdition,
} from './test-utils'
vi.mock('@/config', () => ({ IS_CE_EDITION: false }))
afterEach(cleanup)
describe('APIKeyInfoPanel - Cloud Edition', () => {
@@ -16,6 +15,7 @@ describe('APIKeyInfoPanel - Cloud Edition', () => {
beforeEach(() => {
clearAllMocks()
setDeploymentEdition('CLOUD')
mockUseModalContext.mockReturnValue({
...defaultModalContext,
setShowAccountSettingModal,
@@ -5,11 +5,10 @@ import {
interactions,
mockUseModalContext,
scenarios,
setDeploymentEdition,
textKeys,
} from './test-utils'
vi.mock('@/config', () => ({ IS_CE_EDITION: true }))
afterEach(cleanup)
describe('APIKeyInfoPanel - Community Edition', () => {
@@ -17,6 +16,7 @@ describe('APIKeyInfoPanel - Community Edition', () => {
beforeEach(() => {
clearAllMocks()
setDeploymentEdition('COMMUNITY')
mockUseModalContext.mockReturnValue({
...defaultModalContext,
setShowAccountSettingModal,
@@ -1,7 +1,8 @@
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, render, screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import { noop } from 'es-toolkit/function'
import { defaultPlan } from '@/app/components/billing/config'
import {
@@ -9,6 +10,7 @@ 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(() => ({
@@ -102,6 +104,7 @@ type APIKeyInfoPanelRenderOptions = {
} & Omit<RenderOptions, 'wrapper'>
const mainButtonName = /appOverview\.apiKeyInfo\.setAPIBtn/
let deploymentEdition: DeploymentEdition = 'COMMUNITY'
// Setup function to configure mocks
function setupMocks(overrides: MockOverrides = {}) {
@@ -129,7 +132,10 @@ function renderAPIKeyInfoPanel(options: APIKeyInfoPanelRenderOptions = {}) {
setupMocks(mockOverrides)
return render(<APIKeyInfoPanel />, renderOptions)
return renderWithConsoleQuery(<APIKeyInfoPanel />, {
...renderOptions,
systemFeatures: { deployment_edition: deploymentEdition },
})
}
// Helper functions for common test scenarios
@@ -200,5 +206,9 @@ export function clearAllMocks() {
vi.clearAllMocks()
}
export function setDeploymentEdition(value: DeploymentEdition) {
deploymentEdition = value
}
// Export mock functions for external access
export { defaultModalContext, mockUseModalContext }
@@ -3,17 +3,22 @@ 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 isCloud = !IS_CE_EDITION
const { data: deploymentEdition } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: ({ deployment_edition }) => deployment_edition,
})
const isCloud = deploymentEdition === 'CLOUD'
const { isAPIKeySet } = useProviderContext()
const openIntegrationsSetting = useIntegrationsSetting()

Some files were not shown because too many files have changed in this diff Show More