test: centralize app and account config overrides (#40860)

This commit is contained in:
Asuka Minato
2026-08-25 06:19:35 +00:00
committed by GitHub
parent bc845ea748
commit f3bb73b671
11 changed files with 273 additions and 256 deletions
@@ -3,6 +3,7 @@ from __future__ import annotations
import builtins
import json
import sys
from collections.abc import Callable
from datetime import datetime
from importlib import util
from pathlib import Path
@@ -17,7 +18,6 @@ from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from werkzeug.datastructures import MultiDict
from configs import dify_config
from models.model import App, AppMode, IconType
from models.workflow import Workflow, WorkflowType
@@ -594,7 +594,10 @@ def test_app_list_uses_injected_session_for_draft_workflows(
assert response["data"][0]["permission_keys"] == ["app.acl.edit"]
def test_app_create_api_attaches_permission_keys(app, app_module, unbound_session: Session):
def test_app_create_api_attaches_permission_keys(
app, app_module, unbound_session: Session, config_overrides: Callable[..., None]
):
config_overrides(RBAC_ENABLED=True)
method = app_module.AppListApi.post
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
@@ -611,7 +614,6 @@ def test_app_create_api_attaches_permission_keys(app, app_module, unbound_sessio
with app.test_request_context("/apps", method="POST", json={}):
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(app_module.dify_config, "RBAC_ENABLED", True)
app_module.console_ns.payload = {
"name": "Created App",
"description": "Summary",
@@ -658,7 +660,10 @@ def test_app_create_api_attaches_permission_keys(app, app_module, unbound_sessio
initialize_rbac_task.delay.assert_called_once_with("tenant-1", "acct-1", app_id="app-new")
def test_app_list_api_attaches_permission_keys(app, app_module, sqlite_session: Session):
def test_app_list_api_attaches_permission_keys(
app, app_module, sqlite_session: Session, config_overrides: Callable[..., None]
):
config_overrides(RBAC_ENABLED=True)
method = app_module.AppListApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
@@ -678,7 +683,6 @@ def test_app_list_api_attaches_permission_keys(app, app_module, sqlite_session:
with app.test_request_context("/apps"):
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(dify_config, "RBAC_ENABLED", True)
monkeypatch.setattr(
app_module.AppService,
"get_paginate_apps",
@@ -719,7 +723,13 @@ def test_app_list_api_attaches_permission_keys(app, app_module, sqlite_session:
assert resp["data"][0]["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"]
def test_recent_app_list_api_returns_only_home_card_fields(app, app_module, unbound_session: Session):
def test_recent_app_list_api_returns_only_home_card_fields(
app,
app_module,
unbound_session: Session,
config_overrides: Callable[..., None],
):
config_overrides(RBAC_ENABLED=False)
method = app_module.RecentAppListApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
@@ -739,7 +749,6 @@ def test_recent_app_list_api_returns_only_home_card_fields(app, app_module, unbo
with app.test_request_context("/apps/recent?limit=8"):
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(dify_config, "RBAC_ENABLED", False)
monkeypatch.setattr(app_module.AppService, "get_recent_apps", get_recent_apps)
monkeypatch.setattr(
app_module.enterprise_rbac_service.RBACService.MyPermissions,
@@ -797,7 +806,10 @@ def test_recent_app_response_rejects_non_home_app_modes(app_module, mode: str) -
)
def test_recent_app_list_api_applies_rbac_visibility_filter(app, app_module, unbound_session: Session):
def test_recent_app_list_api_applies_rbac_visibility_filter(
app, app_module, unbound_session: Session, config_overrides: Callable[..., None]
):
config_overrides(RBAC_ENABLED=True)
method = app_module.RecentAppListApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
@@ -805,7 +817,6 @@ def test_recent_app_list_api_applies_rbac_visibility_filter(app, app_module, unb
get_recent_apps = MagicMock(return_value=[])
with app.test_request_context("/apps/recent"):
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(dify_config, "RBAC_ENABLED", True)
monkeypatch.setattr(app_module.AppService, "get_recent_apps", get_recent_apps)
monkeypatch.setattr(
app_module.enterprise_rbac_service.RBACService.MyPermissions,
@@ -835,8 +846,9 @@ def test_recent_app_list_api_applies_rbac_visibility_filter(app, app_module, unb
def test_app_list_api_limits_to_apps_created_by_current_user_without_view_permission(
app, app_module, unbound_session: Session
app, app_module, unbound_session: Session, config_overrides: Callable[..., None]
):
config_overrides(RBAC_ENABLED=True)
method = app_module.AppListApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
@@ -847,7 +859,6 @@ def test_app_list_api_limits_to_apps_created_by_current_user_without_view_permis
with app.test_request_context("/apps"):
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(app_module.AppService, "get_paginate_apps", get_paginate_apps)
monkeypatch.setattr(app_module.dify_config, "RBAC_ENABLED", True)
monkeypatch.setattr(
app_module.enterprise_rbac_service.RBACService.MyPermissions,
"get",
@@ -879,8 +890,9 @@ def test_app_list_api_limits_to_apps_created_by_current_user_without_view_permis
def test_app_list_api_limits_to_preview_overrides_without_manage_own_permission(
app, app_module, unbound_session: Session
app, app_module, unbound_session: Session, config_overrides: Callable[..., None]
):
config_overrides(RBAC_ENABLED=True)
method = app_module.AppListApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
@@ -891,7 +903,6 @@ def test_app_list_api_limits_to_preview_overrides_without_manage_own_permission(
with app.test_request_context("/apps"):
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(app_module.AppService, "get_paginate_apps", get_paginate_apps)
monkeypatch.setattr(app_module.dify_config, "RBAC_ENABLED", True)
monkeypatch.setattr(
app_module.enterprise_rbac_service.RBACService.MyPermissions,
"get",
@@ -936,8 +947,9 @@ def test_app_list_api_limits_to_preview_overrides_without_manage_own_permission(
def test_app_list_api_returns_no_apps_without_workspace_or_resource_view_permission(
app, app_module, unbound_session: Session
app, app_module, unbound_session: Session, config_overrides: Callable[..., None]
):
config_overrides(RBAC_ENABLED=True)
method = app_module.AppListApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
@@ -948,7 +960,6 @@ def test_app_list_api_returns_no_apps_without_workspace_or_resource_view_permiss
with app.test_request_context("/apps"):
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(app_module.AppService, "get_paginate_apps", get_paginate_apps)
monkeypatch.setattr(app_module.dify_config, "RBAC_ENABLED", True)
monkeypatch.setattr(
app_module.enterprise_rbac_service.RBACService.MyPermissions,
"get",
@@ -973,7 +984,10 @@ def test_app_list_api_returns_no_apps_without_workspace_or_resource_view_permiss
assert params.is_created_by_me is None
def test_app_detail_api_attaches_current_user_permission_keys(app, app_module, unbound_session: Session):
def test_app_detail_api_attaches_current_user_permission_keys(
app, app_module, unbound_session: Session, config_overrides: Callable[..., None]
):
config_overrides(RBAC_ENABLED=True)
method = app_module.AppApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
@@ -990,7 +1004,6 @@ def test_app_detail_api_attaches_current_user_permission_keys(app, app_module, u
with app.test_request_context("/apps/app-1"):
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(dify_config, "RBAC_ENABLED", True)
get_app = MagicMock(return_value=app_obj)
monkeypatch.setattr(app_module, "AppService", lambda: SimpleNamespace(get_app=get_app))
monkeypatch.setattr(
@@ -1039,7 +1052,14 @@ def test_app_detail_api_attaches_current_user_permission_keys(app, app_module, u
]
def test_app_copy_api_attaches_permission_keys(app, app_module, sqlite_session: Session, sqlite_engine: Engine):
def test_app_copy_api_attaches_permission_keys(
app,
app_module,
sqlite_session: Session,
sqlite_engine: Engine,
config_overrides: Callable[..., None],
):
config_overrides(RBAC_ENABLED=True)
method = app_module.AppCopyApi.post
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
@@ -1063,7 +1083,6 @@ def test_app_copy_api_attaches_permission_keys(app, app_module, sqlite_session:
with app.test_request_context("/apps/app-original/copy", method="POST", json={}):
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(dify_config, "RBAC_ENABLED", True)
monkeypatch.setattr(
app_module,
"AppDslService",
@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Callable
from unittest.mock import MagicMock, patch
import pytest
@@ -24,6 +25,11 @@ from services.errors.account import (
)
@pytest.fixture(autouse=True)
def _cloud_edition(config_overrides: Callable[..., None]) -> None:
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
class TestEmailRegisterSendEmailApi:
@patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback")
@patch("controllers.console.auth.email_register.AccountService.send_email_register_email")
@@ -50,8 +56,6 @@ class TestEmailRegisterSendEmailApi:
is_allow_register=True,
)
with (
patch("controllers.console.auth.email_register.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
):
with app.test_request_context(
@@ -138,7 +142,6 @@ class TestEmailRegisterCheckApi:
is_allow_register=True,
)
with (
patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
):
with app.test_request_context(
@@ -212,7 +215,6 @@ class TestEmailRegisterResetApi:
is_allow_register=True,
)
with (
patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
):
with app.test_request_context(
@@ -265,7 +267,6 @@ class TestEmailRegisterResetApi:
is_allow_register=True,
)
with (
patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
):
with app.test_request_context(
@@ -323,7 +324,6 @@ class TestEmailRegisterResetApi:
is_allow_register=True,
)
with (
patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
):
with app.test_request_context(
@@ -9,6 +9,7 @@ This module tests the email code login mechanism including:
"""
import base64
from collections.abc import Callable
from unittest.mock import ANY, MagicMock, patch
import pytest
@@ -56,6 +57,11 @@ from services.turnstile_service import TurnstileChallengeRejectedError, Turnstil
TEST_TOKEN = "00000000-0000-4000-8000-000000000001"
@pytest.fixture(autouse=True)
def _default_edition(config_overrides: Callable[..., None]) -> None:
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY)
def encode_code(code: str) -> str:
"""Helper to encode verification code as Base64 for testing."""
return base64.b64encode(code.encode("utf-8")).decode()
@@ -197,7 +203,14 @@ class TestEmailCodeLoginSendEmailApi:
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.AccountService.is_email_send_ip_limit")
@patch("controllers.console.auth.login.TurnstileService.verify")
def test_send_email_code_ip_rate_limited(self, mock_verify, mock_is_ip_limit, mock_db, app: Flask):
def test_send_email_code_ip_rate_limited(
self,
mock_verify,
mock_is_ip_limit,
mock_db,
app: Flask,
config_overrides: Callable[..., None],
):
"""
Test email code sending blocked by IP rate limit.
@@ -205,12 +218,11 @@ class TestEmailCodeLoginSendEmailApi:
- EmailSendIpLimitError is raised when IP limit exceeded
- Prevents spam and abuse
"""
# Arrange
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
mock_is_ip_limit.return_value = True
# Act & Assert
with (
patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
app.test_request_context("/email-code-login", method="POST", json={"email": "test@example.com"}),
):
with pytest.raises(EmailSendIpLimitError):
@@ -232,11 +244,12 @@ class TestEmailCodeLoginSendEmailApi:
mock_db,
app: Flask,
mock_account,
config_overrides: Callable[..., None],
):
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
mock_get_user.return_value = mock_account
with (
patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
app.test_request_context(
"/email-code-login",
method="POST",
@@ -268,9 +281,10 @@ class TestEmailCodeLoginSendEmailApi:
app: Flask,
service_error: Exception,
http_error: type[Exception],
config_overrides: Callable[..., None],
):
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
with (
patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
patch("controllers.console.auth.login.TurnstileService.verify", side_effect=service_error),
app.test_request_context(
"/email-code-login",
@@ -297,11 +311,12 @@ class TestEmailCodeLoginSendEmailApi:
mock_db,
app: Flask,
mock_account,
config_overrides: Callable[..., None],
):
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY)
mock_get_user.return_value = mock_account
with (
patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY),
app.test_request_context("/email-code-login", method="POST", json={"email": "test@example.com"}),
):
response = EmailCodeLoginSendEmailApi().post()
@@ -10,6 +10,7 @@ This module tests the core authentication endpoints including:
import base64
import logging
from collections.abc import Callable
from unittest.mock import ANY, MagicMock, Mock, patch
import pytest
@@ -47,6 +48,11 @@ from services.errors.account import (
TEST_TOKEN = "00000000-0000-4000-8000-000000000001"
@pytest.fixture(autouse=True)
def _login_config(config_overrides: Callable[..., None]) -> None:
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY)
def encode_password(password: str) -> str:
"""Helper to encode password as Base64 for testing."""
return base64.b64encode(password.encode("utf-8")).decode()
@@ -98,7 +104,6 @@ class TestLoginApi:
return token_pair
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.login.AccountService.authenticate")
@@ -149,7 +154,6 @@ class TestLoginApi:
assert response.json["result"] == "success"
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.login.AccountService.authenticate")
@@ -202,7 +206,6 @@ class TestLoginApi:
assert response.json["result"] == "success"
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
def test_login_fails_when_rate_limited(
@@ -235,10 +238,14 @@ class TestLoginApi:
assert warn_records[0].args[1] == LoginFailureReason.LOGIN_RATE_LIMITED
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD)
@patch("controllers.console.auth.login.BillingService.get_email_freeze_type")
def test_login_fails_when_account_frozen(
self, mock_is_frozen, mock_db, app: Flask, caplog: pytest.LogCaptureFixture
self,
mock_get_freeze_type,
mock_db,
app: Flask,
caplog: pytest.LogCaptureFixture,
config_overrides: Callable[..., None],
):
"""
Test login rejection for frozen accounts.
@@ -248,7 +255,8 @@ class TestLoginApi:
- AccountInFreezeError is raised for frozen accounts
"""
# Arrange
mock_is_frozen.return_value = "freeze"
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
mock_get_freeze_type.return_value = "freeze"
# Act & Assert
with app.test_request_context(
@@ -266,9 +274,15 @@ class TestLoginApi:
assert warn_records[0].args[1] == LoginFailureReason.ACCOUNT_IN_FREEZE
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD)
@patch("controllers.console.auth.login.BillingService.get_email_freeze_type")
def test_login_fails_when_email_domain_is_suspended(self, mock_get_freeze_type, mock_db, app: Flask):
def test_login_fails_when_email_domain_is_suspended(
self,
mock_get_freeze_type,
mock_db,
app: Flask,
config_overrides: Callable[..., None],
):
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
mock_get_freeze_type.return_value = "email_domain_suspended"
with app.test_request_context(
@@ -376,7 +390,6 @@ class TestLoginApi:
ResetPasswordSendEmailApi().post()
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.login.AccountService.authenticate")
@@ -423,7 +436,6 @@ class TestLoginApi:
assert warn_records[0].args[1] == LoginFailureReason.INVALID_CREDENTIALS
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.login.AccountService.authenticate")
@@ -460,7 +472,6 @@ class TestLoginApi:
assert warn_records[0].args[1] == LoginFailureReason.ACCOUNT_BANNED
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.login.AccountService.authenticate")
@@ -504,7 +515,6 @@ class TestLoginApi:
login_api.post()
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
def test_login_invitation_email_mismatch(self, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask):
@@ -534,7 +544,6 @@ class TestLoginApi:
login_api.post()
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.login.AccountService.authenticate")
@@ -1,3 +1,4 @@
from collections.abc import Callable
from types import SimpleNamespace
from typing import override
from unittest.mock import MagicMock, patch
@@ -52,6 +53,15 @@ def reset_setup_required_cache():
_is_setup_completed.reset_success()
@pytest.fixture(autouse=True)
def _wraps_config(config_overrides: Callable[..., None]) -> None:
config_overrides(
RBAC_ENABLED=True,
DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY,
INIT_PASSWORD="",
)
class MockUser(UserMixin):
"""Simple User class for testing."""
@@ -399,7 +409,6 @@ class TestRbacPermissionRequired:
return "ok"
with (
patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True),
patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-1")),
patch("controllers.common.wraps._extract_resource_id", return_value="app-123") as mock_extract,
patch("controllers.common.wraps._is_resource_owned_by_current_user", return_value=False) as mock_owned,
@@ -427,7 +436,6 @@ class TestRbacPermissionRequired:
return "ok"
with (
patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True),
patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-2")),
patch("controllers.common.wraps._extract_resource_id") as mock_extract,
patch("controllers.common.wraps._is_resource_owned_by_current_user", return_value=False) as mock_owned,
@@ -455,7 +463,6 @@ class TestRbacPermissionRequired:
return "ok"
with (
patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True),
patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-3")),
patch("controllers.common.wraps.RBACService.CheckAccess.check", return_value=True) as mock_check,
):
@@ -477,7 +484,6 @@ class TestRbacPermissionRequired:
return "ok"
with (
patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True),
patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-4")),
patch("controllers.common.wraps._extract_resource_id", return_value="app-123"),
patch("controllers.common.wraps._is_resource_owned_by_current_user", return_value=True) as mock_owned,
@@ -496,7 +502,6 @@ class TestRbacPermissionRequired:
return "ok"
with (
patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True),
patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-5")),
patch("controllers.common.wraps._extract_resource_id", return_value="dataset-123"),
patch("controllers.common.wraps._is_resource_owned_by_current_user", return_value=True) as mock_owned,
@@ -610,8 +615,7 @@ class TestRbacPermissionRequired:
def protected_view():
return "ok"
with patch("controllers.console.wraps.dify_config.RBAC_ENABLED", True):
assert protected_view() == "ok"
assert protected_view() == "ok"
class TestModelValidationInjection:
@@ -668,7 +672,7 @@ class TestModelValidationInjection:
class TestEditionChecks:
"""Test edition-specific decorators"""
def test_only_edition_cloud_allows_cloud_edition(self):
def test_only_edition_cloud_allows_cloud_edition(self, config_overrides: Callable[..., None]):
"""Test cloud edition decorator allows CLOUD edition"""
# Arrange
@@ -676,9 +680,8 @@ class TestEditionChecks:
def cloud_view():
return "cloud_success"
# Act
with patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD):
result = cloud_view()
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
result = cloud_view()
# Assert
assert result == "cloud_success"
@@ -694,12 +697,11 @@ class TestEditionChecks:
# Act & Assert
with app.test_request_context():
with patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY):
with pytest.raises(HTTPException) as exc_info:
cloud_view()
assert exc_info.value.code == 404
with pytest.raises(HTTPException) as exc_info:
cloud_view()
assert exc_info.value.code == 404
def test_only_edition_enterprise_allows_enterprise_edition(self):
def test_only_edition_enterprise_allows_enterprise_edition(self, config_overrides: Callable[..., None]):
"""Test enterprise edition decorator allows the ENTERPRISE edition."""
# Arrange
@@ -707,9 +709,8 @@ class TestEditionChecks:
def enterprise_view():
return "enterprise_success"
# Act
with patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE):
result = enterprise_view()
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE)
result = enterprise_view()
# Assert
assert result == "enterprise_success"
@@ -722,9 +723,7 @@ class TestEditionChecks:
def self_hosted_view():
return "self_hosted_success"
# Act
with patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY):
result = self_hosted_view()
result = self_hosted_view()
# Assert
assert result == "self_hosted_success"
@@ -805,8 +804,9 @@ class TestBillingResourceLimits:
assert result == "member_added"
get_features.assert_called_once_with("tenant123", exclude_vector_space=True)
def test_should_load_vector_space_from_dedicated_quota_api(self):
def test_should_load_vector_space_from_dedicated_quota_api(self, config_overrides: Callable[..., None]):
"""Test vector-space limit checks avoid loading the full feature payload."""
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
# Arrange
mock_vector_space = MagicMock()
mock_vector_space.limit = 10
@@ -821,7 +821,6 @@ class TestBillingResourceLimits:
"controllers.console.wraps.current_account_with_tenant", return_value=(MockUser("test_user"), "tenant123")
):
with (
patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
patch(
"controllers.console.wraps.FeatureService.get_vector_space", return_value=mock_vector_space
) as get_vector_space,
@@ -984,8 +983,9 @@ class TestRateLimiting:
class TestCloudUtmRecord:
"""Test cloud UTM recording decorator."""
def test_should_record_utm_for_cloud_edition_and_cookie(self):
def test_should_record_utm_for_cloud_edition_and_cookie(self, config_overrides: Callable[..., None]):
"""Test Cloud UTM recording without loading tenant features."""
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
app = create_app_with_login()
@cloud_utm_record
@@ -994,7 +994,6 @@ class TestCloudUtmRecord:
with app.test_request_context("/", headers={"Cookie": "utm_info={}"}):
with (
patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
patch("controllers.console.wraps.current_account_with_tenant", return_value=(MockUser("u1"), "t1")),
patch("controllers.console.wraps.OperationService.record_utm") as record_utm,
patch("controllers.console.wraps.FeatureService.get_features") as get_features,
@@ -1015,7 +1014,6 @@ class TestCloudUtmRecord:
with app.test_request_context("/", headers={"Cookie": "utm_info={}"}):
with (
patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY),
patch("controllers.console.wraps.current_account_with_tenant") as current_account,
patch("controllers.console.wraps.OperationService.record_utm") as record_utm,
patch("controllers.console.wraps.FeatureService.get_features") as get_features,
@@ -1047,10 +1045,7 @@ class TestSystemSetup:
return "admin_success"
# Act
with (
patch("controllers.console.wraps.db.session", sqlite_session),
patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY),
):
with patch("controllers.console.wraps.db.session", sqlite_session):
result = admin_view()
# Assert
@@ -1064,10 +1059,7 @@ class TestSystemSetup:
def admin_view():
return "admin_success"
with (
patch("controllers.console.wraps.db.session", sqlite_session),
patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY),
):
with patch("controllers.console.wraps.db.session", sqlite_session):
assert admin_view() == "admin_success"
sqlite_session.delete(setup)
sqlite_session.commit()
@@ -1084,27 +1076,23 @@ class TestSystemSetup:
with (
patch("controllers.console.wraps.db.session", sqlite_session),
patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY),
patch("controllers.console.wraps.dify_config.INIT_PASSWORD", ""),
):
with pytest.raises(NotSetupError):
admin_view()
self._complete_setup(sqlite_session)
assert admin_view() == "admin_success"
def test_should_raise_not_init_validate_error_with_init_password(self, sqlite_session: Session):
def test_should_raise_not_init_validate_error_with_init_password(
self, sqlite_session: Session, config_overrides: Callable[..., None]
):
"""Test NotInitValidateError when INIT_PASSWORD is set but setup not complete"""
@setup_required
def admin_view():
return "admin_success"
# Act & Assert
with (
patch("controllers.console.wraps.db.session", sqlite_session),
patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY),
patch("controllers.console.wraps.dify_config.INIT_PASSWORD", "some_password"),
):
config_overrides(INIT_PASSWORD="some_password")
with patch("controllers.console.wraps.db.session", sqlite_session):
with pytest.raises(NotInitValidateError):
admin_view()
@@ -1116,11 +1104,7 @@ class TestSystemSetup:
return "admin_success"
# Act & Assert
with (
patch("controllers.console.wraps.db.session", sqlite_session),
patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY),
patch("controllers.console.wraps.dify_config.INIT_PASSWORD", ""),
):
with patch("controllers.console.wraps.db.session", sqlite_session):
with pytest.raises(NotSetupError):
admin_view()
@@ -1,4 +1,5 @@
import uuid
from collections.abc import Callable
from unittest.mock import patch
import pytest
@@ -24,6 +25,11 @@ from models.model import App
from services.enterprise.enterprise_service import WebAppAccessMode
@pytest.fixture(autouse=True)
def _rbac_config(config_overrides: Callable[..., None]) -> None:
config_overrides(RBAC_ENABLED=True)
def _data(**kwargs) -> AuthData:
defaults: dict = {"token_type": TokenType.OAUTH_ACCOUNT, "token_hash": "hash", "scopes": frozenset({Scope.FULL})}
defaults.update(kwargs)
@@ -88,18 +94,15 @@ def test_check_rbac_noop_when_no_requirement():
mock_enforce.assert_not_called()
def test_check_rbac_noop_when_rbac_disabled():
with (
patch("controllers.openapi.auth.verify.dify_config.RBAC_ENABLED", False),
patch("controllers.openapi.auth.verify.enforce_rbac_access") as mock_enforce,
):
def test_check_rbac_noop_when_rbac_disabled(config_overrides: Callable[..., None]):
config_overrides(RBAC_ENABLED=False)
with patch("controllers.openapi.auth.verify.enforce_rbac_access") as mock_enforce:
check_rbac_permission(_data(rbac=_RBAC_REQ, caller_kind="account"))
mock_enforce.assert_not_called()
def test_check_rbac_skips_end_user_caller():
with (
patch("controllers.openapi.auth.verify.dify_config.RBAC_ENABLED", True),
patch("controllers.openapi.auth.verify.enforce_rbac_access") as mock_enforce,
):
check_rbac_permission(_data(rbac=_RBAC_REQ, caller_kind="end_user"))
@@ -107,9 +110,8 @@ def test_check_rbac_skips_end_user_caller():
def test_check_rbac_raises_when_context_missing():
with patch("controllers.openapi.auth.verify.dify_config.RBAC_ENABLED", True):
with pytest.raises(Forbidden, match="rbac context missing"):
check_rbac_permission(_data(rbac=_RBAC_REQ, caller_kind="account", account_id=None, tenant=None))
with pytest.raises(Forbidden, match="rbac context missing"):
check_rbac_permission(_data(rbac=_RBAC_REQ, caller_kind="account", account_id=None, tenant=None))
def test_check_rbac_enforces_for_account_caller():
@@ -124,7 +126,6 @@ def test_check_rbac_enforces_for_account_caller():
path_params={"app_id": "app-1"},
)
with (
patch("controllers.openapi.auth.verify.dify_config.RBAC_ENABLED", True),
patch("controllers.openapi.auth.verify.enforce_rbac_access") as mock_enforce,
):
check_rbac_permission(data)
@@ -3,7 +3,7 @@ from __future__ import annotations
import base64
import hashlib
import hmac
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -140,10 +140,11 @@ def test_resolve_file_url_requires_extension_for_tool_files() -> None:
def test_resolve_file_url_uses_tool_signatures_for_tool_and_datasource_files(
monkeypatch: pytest.MonkeyPatch,
config_overrides: Callable[..., None],
) -> None:
sign_tool_file_uri = MagicMock(return_value="/files/signed")
monkeypatch.setattr(file_runtime, "sign_tool_file_uri", sign_tool_file_uri)
monkeypatch.setattr(file_runtime.dify_config, "FILES_URL", "https://files.example.com")
config_overrides(FILES_URL="https://files.example.com")
runtime = _build_runtime()
tool_file = _build_file(
@@ -175,9 +176,11 @@ def test_resolve_file_uri_keeps_dify_owned_file_origin_free(monkeypatch: pytest.
assert runtime.resolve_file_uri(file=file) == "/files/tools/tool-file-id.png?sign=1"
def test_resolve_file_url_returns_relative_uri_when_files_url_is_empty(monkeypatch: pytest.MonkeyPatch) -> None:
def test_resolve_file_url_returns_relative_uri_when_files_url_is_empty(
monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]
) -> None:
monkeypatch.setattr(file_runtime, "sign_tool_file_uri", lambda **_: "/files/tools/tool-file-id.png?sign=1")
monkeypatch.setattr(file_runtime.dify_config, "FILES_URL", "")
config_overrides(FILES_URL="")
runtime = _build_runtime()
file = _build_file(
transfer_method=FileTransferMethod.TOOL_FILE,
@@ -190,15 +193,15 @@ def test_resolve_file_url_returns_relative_uri_when_files_url_is_empty(monkeypat
def test_resolve_upload_file_url_signs_internal_urls_and_supports_attachments(
monkeypatch: pytest.MonkeyPatch,
config_overrides: Callable[..., None],
) -> None:
config_overrides(
SECRET_KEY="unit-secret",
FILES_URL="https://files.example.com",
INTERNAL_FILES_URL="https://internal.example.com",
)
monkeypatch.setattr("core.app.workflow.file_runtime.time.time", lambda: 1700000000)
monkeypatch.setattr("core.app.workflow.file_runtime.os.urandom", lambda _: b"\x01" * 16)
monkeypatch.setattr("core.app.workflow.file_runtime.dify_config.SECRET_KEY", "unit-secret")
monkeypatch.setattr("core.app.workflow.file_runtime.dify_config.FILES_URL", "https://files.example.com")
monkeypatch.setattr(
"core.app.workflow.file_runtime.dify_config.INTERNAL_FILES_URL",
"https://internal.example.com",
)
runtime = _build_runtime()
url = runtime.resolve_upload_file_url(
@@ -215,10 +218,11 @@ def test_resolve_upload_file_url_signs_internal_urls_and_supports_attachments(
assert query["timestamp"] == ["1700000000"]
def test_verify_preview_signature_validates_signature_and_expiration(monkeypatch: pytest.MonkeyPatch) -> None:
def test_verify_preview_signature_validates_signature_and_expiration(
monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]
) -> None:
config_overrides(SECRET_KEY="unit-secret", FILES_ACCESS_TIMEOUT=60)
monkeypatch.setattr("core.app.workflow.file_runtime.time.time", lambda: 1700000000)
monkeypatch.setattr("core.app.workflow.file_runtime.dify_config.SECRET_KEY", "unit-secret")
monkeypatch.setattr("core.app.workflow.file_runtime.dify_config.FILES_ACCESS_TIMEOUT", 60)
runtime = _build_runtime()
payload = "file-preview|upload-file-id|1700000000|nonce"
sign = base64.urlsafe_b64encode(hmac.new(b"unit-secret", payload.encode(), hashlib.sha256).digest()).decode()
@@ -372,8 +376,10 @@ def test_resolve_storage_key_raises_when_records_are_missing(
runtime._resolve_storage_key(file=file)
def test_runtime_helper_wrappers_delegate_to_config_and_io(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("core.app.workflow.file_runtime.dify_config.MULTIMODAL_SEND_FORMAT", "url")
def test_runtime_helper_wrappers_delegate_to_config_and_io(
monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]
) -> None:
config_overrides(MULTIMODAL_SEND_FORMAT="url")
runtime = _build_runtime()
assert runtime.multimodal_send_format == "url"
@@ -1,3 +1,4 @@
from collections.abc import Callable
from types import SimpleNamespace
import pytest
@@ -39,6 +40,22 @@ class DummyDocumentExtractorNode(DummyNode):
class TestDifyNodeFactory:
@pytest.fixture(autouse=True)
def _node_config(self, config_overrides: Callable[..., None]) -> None:
config_overrides(
CODE_MAX_STRING_LENGTH=10,
CODE_MAX_NUMBER=10,
CODE_MIN_NUMBER=-10,
CODE_MAX_PRECISION=4,
CODE_MAX_DEPTH=2,
CODE_MAX_NUMBER_ARRAY_LENGTH=2,
CODE_MAX_STRING_ARRAY_LENGTH=2,
CODE_MAX_OBJECT_ARRAY_LENGTH=2,
TEMPLATE_TRANSFORM_MAX_LENGTH=100,
UNSTRUCTURED_API_URL="http://u",
UNSTRUCTURED_API_KEY="key",
)
@staticmethod
def _stub_node_resolution(monkeypatch: pytest.MonkeyPatch, node_class):
monkeypatch.setattr(
@@ -46,19 +63,7 @@ class TestDifyNodeFactory:
lambda **_kwargs: node_class,
)
def _factory(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("core.workflow.node_factory.dify_config.CODE_MAX_STRING_LENGTH", 10)
monkeypatch.setattr("core.workflow.node_factory.dify_config.CODE_MAX_NUMBER", 10)
monkeypatch.setattr("core.workflow.node_factory.dify_config.CODE_MIN_NUMBER", -10)
monkeypatch.setattr("core.workflow.node_factory.dify_config.CODE_MAX_PRECISION", 4)
monkeypatch.setattr("core.workflow.node_factory.dify_config.CODE_MAX_DEPTH", 2)
monkeypatch.setattr("core.workflow.node_factory.dify_config.CODE_MAX_NUMBER_ARRAY_LENGTH", 2)
monkeypatch.setattr("core.workflow.node_factory.dify_config.CODE_MAX_STRING_ARRAY_LENGTH", 2)
monkeypatch.setattr("core.workflow.node_factory.dify_config.CODE_MAX_OBJECT_ARRAY_LENGTH", 2)
monkeypatch.setattr("core.workflow.node_factory.dify_config.TEMPLATE_TRANSFORM_MAX_LENGTH", 100)
monkeypatch.setattr("core.workflow.node_factory.dify_config.UNSTRUCTURED_API_URL", "http://u")
monkeypatch.setattr("core.workflow.node_factory.dify_config.UNSTRUCTURED_API_KEY", "key")
def _factory(self):
run_context = build_dify_run_context(
tenant_id="tenant",
app_id="app",
@@ -72,21 +77,21 @@ class TestDifyNodeFactory:
graph_runtime_state=SimpleNamespace(),
)
def test_create_node_unknown_type(self, monkeypatch: pytest.MonkeyPatch):
factory = self._factory(monkeypatch)
def test_create_node_unknown_type(self):
factory = self._factory()
with pytest.raises(ValueError):
factory.create_node({"id": "node-1", "data": {"type": "unknown"}})
def test_create_node_missing_mapping(self, monkeypatch: pytest.MonkeyPatch):
factory = self._factory(monkeypatch)
factory = self._factory()
monkeypatch.setattr("core.workflow.node_factory.get_node_type_classes_mapping", lambda: {})
with pytest.raises(ValueError):
factory.create_node({"id": "node-1", "data": {"type": BuiltinNodeTypes.START}})
def test_create_node_missing_latest_class(self, monkeypatch: pytest.MonkeyPatch):
factory = self._factory(monkeypatch)
factory = self._factory()
monkeypatch.setattr(
"core.workflow.node_factory.get_node_type_classes_mapping",
lambda: {BuiltinNodeTypes.START: {"1": None}},
@@ -97,7 +102,7 @@ class TestDifyNodeFactory:
factory.create_node({"id": "node-1", "data": {"type": BuiltinNodeTypes.START}})
def test_create_node_selects_versioned_class(self, monkeypatch: pytest.MonkeyPatch):
factory = self._factory(monkeypatch)
factory = self._factory()
selected_versions: list[tuple[str, str]] = []
class DummyNodeV2(DummyNode):
@@ -116,7 +121,7 @@ class TestDifyNodeFactory:
assert selected_versions == [("snapshot", "called")]
def test_create_node_code_branch(self, monkeypatch: pytest.MonkeyPatch):
factory = self._factory(monkeypatch)
factory = self._factory()
self._stub_node_resolution(monkeypatch, DummyCodeNode)
node = factory.create_node({"id": "node-1", "data": {"type": BuiltinNodeTypes.CODE}})
@@ -125,7 +130,7 @@ class TestDifyNodeFactory:
assert node.id == "node-1"
def test_create_node_template_transform_branch(self, monkeypatch: pytest.MonkeyPatch):
factory = self._factory(monkeypatch)
factory = self._factory()
self._stub_node_resolution(monkeypatch, DummyTemplateTransformNode)
node = factory.create_node({"id": "node-1", "data": {"type": BuiltinNodeTypes.TEMPLATE_TRANSFORM}})
@@ -134,7 +139,7 @@ class TestDifyNodeFactory:
assert "jinja2_template_renderer" in node.kwargs
def test_create_node_http_request_branch(self, monkeypatch: pytest.MonkeyPatch):
factory = self._factory(monkeypatch)
factory = self._factory()
self._stub_node_resolution(monkeypatch, DummyHttpRequestNode)
node = factory.create_node({"id": "node-1", "data": {"type": BuiltinNodeTypes.HTTP_REQUEST}})
@@ -143,7 +148,7 @@ class TestDifyNodeFactory:
assert "http_request_config" in node.kwargs
def test_create_node_knowledge_retrieval_branch(self, monkeypatch: pytest.MonkeyPatch):
factory = self._factory(monkeypatch)
factory = self._factory()
self._stub_node_resolution(monkeypatch, DummyKnowledgeRetrievalNode)
node = factory.create_node({"id": "node-1", "data": {"type": BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL}})
@@ -152,7 +157,7 @@ class TestDifyNodeFactory:
assert node.kwargs == {}
def test_create_node_document_extractor_branch(self, monkeypatch: pytest.MonkeyPatch):
factory = self._factory(monkeypatch)
factory = self._factory()
self._stub_node_resolution(monkeypatch, DummyDocumentExtractorNode)
node = factory.create_node({"id": "node-1", "data": {"type": BuiltinNodeTypes.DOCUMENT_EXTRACTOR}})
@@ -2,7 +2,7 @@ import base64
import hashlib
import hmac
import urllib.parse
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from datetime import UTC, datetime
from unittest.mock import MagicMock
@@ -120,11 +120,14 @@ def _signed_url(*, base_url: str, path: str, payload: str, secret: str = "test-s
return f"{base_url}{path}?{query}"
def _patch_file_fetcher_config(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(remote_fetcher.dify_config, "FILES_URL", "http://localhost:5001")
monkeypatch.setattr(remote_fetcher.dify_config, "INTERNAL_FILES_URL", "http://api:5001")
monkeypatch.setattr(remote_fetcher.dify_config, "SECRET_KEY", "test-secret")
monkeypatch.setattr(remote_fetcher.dify_config, "FILES_ACCESS_TIMEOUT", 3600)
@pytest.fixture(autouse=True)
def _file_fetcher_config(monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]) -> None:
config_overrides(
FILES_URL="http://localhost:5001",
INTERNAL_FILES_URL="http://api:5001",
SECRET_KEY="test-secret",
FILES_ACCESS_TIMEOUT=3600,
)
monkeypatch.setattr(remote_fetcher.time, "time", lambda: 1700000100)
@@ -141,7 +144,6 @@ def _patch_signer_times(monkeypatch: pytest.MonkeyPatch):
def test_get_signed_upload_file_url_reads_storage_without_ssrf(monkeypatch: pytest.MonkeyPatch):
_patch_file_fetcher_config(monkeypatch)
load_once = MagicMock(return_value=b"hello")
monkeypatch.setattr(remote_fetcher.storage, "load_once", load_once)
ssrf_make_request = _patch_ssrf_make_request(monkeypatch)
@@ -165,7 +167,6 @@ def test_get_signed_upload_file_url_reads_storage_without_ssrf(monkeypatch: pyte
def test_make_request_resolves_upload_preview_url_generated_by_signer(
monkeypatch: pytest.MonkeyPatch, file_database: FileDatabase
):
_patch_file_fetcher_config(monkeypatch)
_patch_signer_times(monkeypatch)
file_database.upload_file.key = "upload_files/tenant/image.png"
file_database.upload_file.name = "image.png"
@@ -191,7 +192,6 @@ def test_make_request_resolves_upload_preview_url_generated_by_signer(
def test_make_request_resolves_sign_tool_file_url_with_empty_extension(
monkeypatch: pytest.MonkeyPatch, file_database: FileDatabase
):
_patch_file_fetcher_config(monkeypatch)
_patch_signer_times(monkeypatch)
file_database.tool_file.file_key = "tools/tenant/no-extension"
file_database.tool_file.name = "no-extension"
@@ -216,7 +216,6 @@ def test_make_request_resolves_sign_tool_file_url_with_empty_extension(
def test_make_request_resolves_tool_manager_url_with_empty_extension(
monkeypatch: pytest.MonkeyPatch, file_database: FileDatabase
):
_patch_file_fetcher_config(monkeypatch)
_patch_signer_times(monkeypatch)
file_database.tool_file.file_key = "tools/tenant/manager-file"
file_database.tool_file.name = "manager-file"
@@ -240,7 +239,6 @@ def test_make_request_resolves_tool_manager_url_with_empty_extension(
def test_make_request_resolves_datasource_manager_url_with_empty_extension(
monkeypatch: pytest.MonkeyPatch, file_database: FileDatabase
):
_patch_file_fetcher_config(monkeypatch)
_patch_signer_times(monkeypatch)
file_database.datasource_upload_file.key = "datasources/tenant/no-extension"
file_database.datasource_upload_file.name = "no-extension"
@@ -264,7 +262,6 @@ def test_make_request_resolves_datasource_manager_url_with_empty_extension(
def test_head_signed_upload_file_url_returns_metadata_without_storage_content(monkeypatch: pytest.MonkeyPatch):
_patch_file_fetcher_config(monkeypatch)
load_once = MagicMock(return_value=b"hello")
monkeypatch.setattr(remote_fetcher.storage, "load_once", load_once)
ssrf_make_request = _patch_ssrf_make_request(monkeypatch)
@@ -286,7 +283,6 @@ def test_head_signed_upload_file_url_returns_metadata_without_storage_content(mo
def test_make_request_get_signed_upload_file_url_reads_storage_without_ssrf(monkeypatch: pytest.MonkeyPatch):
_patch_file_fetcher_config(monkeypatch)
load_once = MagicMock(return_value=b"hello")
monkeypatch.setattr(remote_fetcher.storage, "load_once", load_once)
ssrf_make_request = _patch_ssrf_make_request(monkeypatch)
@@ -306,7 +302,6 @@ def test_make_request_get_signed_upload_file_url_reads_storage_without_ssrf(monk
def test_make_request_head_signed_upload_file_url_returns_metadata_without_ssrf(monkeypatch: pytest.MonkeyPatch):
_patch_file_fetcher_config(monkeypatch)
load_once = MagicMock(return_value=b"hello")
monkeypatch.setattr(remote_fetcher.storage, "load_once", load_once)
ssrf_make_request = _patch_ssrf_make_request(monkeypatch)
@@ -330,7 +325,6 @@ def test_make_request_head_signed_upload_file_url_returns_metadata_without_ssrf(
def test_make_request_get_unsigned_dify_url_delegates_to_ssrf_proxy(
monkeypatch: pytest.MonkeyPatch, file_database: FileDatabase
):
_patch_file_fetcher_config(monkeypatch)
url = f"http://localhost:5001/files/{UPLOAD_FILE_ID}/file-preview?timestamp=1700000000&nonce=nonce"
proxy_response = httpx.Response(403, request=httpx.Request("GET", url))
ssrf_make_request = _patch_ssrf_make_request(monkeypatch, proxy_response)
@@ -350,7 +344,6 @@ def test_make_request_get_unsigned_dify_url_delegates_to_ssrf_proxy(
def test_make_request_post_signed_upload_file_url_delegates_to_ssrf_proxy(
monkeypatch: pytest.MonkeyPatch, file_database: FileDatabase
):
_patch_file_fetcher_config(monkeypatch)
proxy_response = httpx.Response(201, request=httpx.Request("POST", f"http://localhost:5001/files/{UPLOAD_FILE_ID}"))
ssrf_make_request = _patch_ssrf_make_request(monkeypatch, proxy_response)
url = _signed_url(
@@ -374,7 +367,6 @@ def test_make_request_post_signed_upload_file_url_delegates_to_ssrf_proxy(
def test_get_signed_image_preview_url_uses_image_preview_signature(
monkeypatch: pytest.MonkeyPatch, file_database: FileDatabase
):
_patch_file_fetcher_config(monkeypatch)
file_database.upload_file.key = "upload_files/tenant/image.png"
file_database.upload_file.name = "image.png"
file_database.upload_file.mime_type = "image/png"
@@ -400,7 +392,6 @@ def test_get_signed_image_preview_url_uses_image_preview_signature(
def test_image_preview_url_with_file_preview_signature_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch):
_patch_file_fetcher_config(monkeypatch)
proxy_response = httpx.Response(403, request=httpx.Request("GET", "http://localhost:5001/bad"))
ssrf_make_request = _patch_ssrf_make_request(monkeypatch, proxy_response)
url = _signed_url(
@@ -420,7 +411,6 @@ def test_image_preview_url_with_file_preview_signature_delegates_to_ssrf_proxy(m
def test_duplicate_signature_query_value_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch):
_patch_file_fetcher_config(monkeypatch)
url = (
_signed_url(
base_url="http://localhost:5001",
@@ -443,7 +433,6 @@ def test_duplicate_signature_query_value_delegates_to_ssrf_proxy(monkeypatch: py
def test_malformed_timestamp_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch):
_patch_file_fetcher_config(monkeypatch)
url = _signed_url(
base_url="http://localhost:5001",
path=f"/files/{UPLOAD_FILE_ID}/file-preview",
@@ -463,7 +452,6 @@ def test_malformed_timestamp_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyP
def test_expired_signature_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch):
_patch_file_fetcher_config(monkeypatch)
monkeypatch.setattr(remote_fetcher.time, "time", lambda: 1700004001)
url = _signed_url(
base_url="http://localhost:5001",
@@ -484,7 +472,6 @@ def test_expired_signature_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPat
def test_invalid_signature_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch):
_patch_file_fetcher_config(monkeypatch)
proxy_response = httpx.Response(403, request=httpx.Request("GET", "http://localhost:5001/bad"))
ssrf_make_request = _patch_ssrf_make_request(monkeypatch, proxy_response)
url = f"http://localhost:5001/files/{UPLOAD_FILE_ID}/file-preview?timestamp=1700000000&nonce=nonce&sign=bad"
@@ -501,7 +488,6 @@ def test_invalid_signature_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPat
def test_host_mismatch_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch):
_patch_file_fetcher_config(monkeypatch)
url = _signed_url(
base_url="http://example.com",
path=f"/files/{UPLOAD_FILE_ID}/file-preview",
@@ -521,7 +507,6 @@ def test_host_mismatch_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch):
def test_unsupported_dify_path_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch):
_patch_file_fetcher_config(monkeypatch)
url = _signed_url(
base_url="http://localhost:5001",
path=f"/files/{UPLOAD_FILE_ID}/not-preview",
@@ -542,7 +527,6 @@ def test_unsupported_dify_path_delegates_to_ssrf_proxy(monkeypatch: pytest.Monke
def test_invalid_url_scheme_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch):
_patch_file_fetcher_config(monkeypatch)
url = f"file:///tmp/files/{UPLOAD_FILE_ID}/file-preview?timestamp=1700000000&nonce=nonce&sign=ignored"
proxy_response = httpx.Response(403, request=httpx.Request("GET", url))
ssrf_make_request = _patch_ssrf_make_request(monkeypatch, proxy_response)
@@ -558,7 +542,6 @@ def test_invalid_url_scheme_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPa
def test_invalid_url_port_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch):
_patch_file_fetcher_config(monkeypatch)
url = f"http://localhost:invalid/files/{UPLOAD_FILE_ID}/file-preview?timestamp=1700000000&nonce=nonce&sign=ignored"
proxy_response = httpx.Response(403, request=httpx.Request("GET", "http://proxy.example/fallback"))
ssrf_make_request = _patch_ssrf_make_request(monkeypatch, proxy_response)
@@ -573,10 +556,10 @@ def test_invalid_url_port_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatc
)
def test_invalid_configured_file_origin_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch):
_patch_file_fetcher_config(monkeypatch)
monkeypatch.setattr(remote_fetcher.dify_config, "FILES_URL", "")
monkeypatch.setattr(remote_fetcher.dify_config, "INTERNAL_FILES_URL", "file:///tmp/files")
def test_invalid_configured_file_origin_delegates_to_ssrf_proxy(
monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]
):
config_overrides(FILES_URL="", INTERNAL_FILES_URL="file:///tmp/files")
url = _signed_url(
base_url="http://localhost:5001",
path=f"/files/{UPLOAD_FILE_ID}/file-preview",
@@ -598,7 +581,6 @@ def test_invalid_configured_file_origin_delegates_to_ssrf_proxy(monkeypatch: pyt
def test_signed_upload_file_url_returns_404_when_record_missing(
monkeypatch: pytest.MonkeyPatch, file_database: FileDatabase
):
_patch_file_fetcher_config(monkeypatch)
file_database.session.delete(file_database.upload_file)
file_database.session.commit()
ssrf_make_request = _patch_ssrf_make_request(monkeypatch)
@@ -616,7 +598,6 @@ def test_signed_upload_file_url_returns_404_when_record_missing(
def test_get_signed_tool_file_url_reads_storage_without_ssrf(monkeypatch: pytest.MonkeyPatch):
_patch_file_fetcher_config(monkeypatch)
load_once = MagicMock(return_value=b"result")
monkeypatch.setattr(remote_fetcher.storage, "load_once", load_once)
ssrf_make_request = _patch_ssrf_make_request(monkeypatch)
@@ -638,7 +619,6 @@ def test_get_signed_tool_file_url_reads_storage_without_ssrf(monkeypatch: pytest
def test_signed_tool_file_url_returns_404_when_record_missing(
monkeypatch: pytest.MonkeyPatch, file_database: FileDatabase
):
_patch_file_fetcher_config(monkeypatch)
file_database.session.delete(file_database.tool_file)
file_database.session.commit()
ssrf_make_request = _patch_ssrf_make_request(monkeypatch)
@@ -656,7 +636,6 @@ def test_signed_tool_file_url_returns_404_when_record_missing(
def test_get_signed_datasource_file_url_reads_upload_storage_without_ssrf(monkeypatch: pytest.MonkeyPatch):
_patch_file_fetcher_config(monkeypatch)
load_once = MagicMock(return_value=b"data")
monkeypatch.setattr(remote_fetcher.storage, "load_once", load_once)
ssrf_make_request = _patch_ssrf_make_request(monkeypatch)
@@ -677,7 +656,6 @@ def test_get_signed_datasource_file_url_reads_upload_storage_without_ssrf(monkey
def test_get_signed_datasource_file_url_reads_tool_storage_when_upload_missing(
monkeypatch: pytest.MonkeyPatch, file_database: FileDatabase
):
_patch_file_fetcher_config(monkeypatch)
file_database.session.delete(file_database.datasource_upload_file)
datasource_tool_file = ToolFile(
user_id=USER_ID,
@@ -713,7 +691,6 @@ def test_get_signed_datasource_file_url_reads_tool_storage_when_upload_missing(
def test_signed_datasource_file_url_returns_404_when_records_missing(
monkeypatch: pytest.MonkeyPatch, file_database: FileDatabase
):
_patch_file_fetcher_config(monkeypatch)
file_database.session.delete(file_database.datasource_upload_file)
file_database.session.commit()
ssrf_make_request = _patch_ssrf_make_request(monkeypatch)
@@ -11,8 +11,8 @@ This test suite covers:
import base64
import secrets
from collections.abc import Callable
from datetime import UTC, datetime
from unittest.mock import patch
from uuid import uuid4
import pytest
@@ -401,6 +401,10 @@ class TestTenantRelationshipIntegrity:
class TestAccountRolePermissions:
@pytest.fixture(autouse=True)
def _rbac_disabled(self, config_overrides: Callable[..., None]) -> None:
config_overrides(RBAC_ENABLED=False)
"""Test suite for account role permissions."""
def test_is_admin_or_owner_with_admin_role(self):
@@ -412,16 +416,14 @@ class TestAccountRolePermissions:
)
account.role = TenantAccountRole.ADMIN
# Act & Assert
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert account.is_admin_or_owner
assert account.is_admin_or_owner
def test_is_admin_or_owner_with_rbac_enabled(self):
def test_is_admin_or_owner_with_rbac_enabled(self, config_overrides: Callable[..., None]):
account = Account(name="Test User", email="test@example.com")
account.role = TenantAccountRole.NORMAL
with patch("models.account.dify_config.RBAC_ENABLED", True):
assert account.is_admin_or_owner
config_overrides(RBAC_ENABLED=True)
assert account.is_admin_or_owner
def test_is_admin_or_owner_with_owner_role(self):
"""Test is_admin_or_owner property with owner role."""
@@ -456,17 +458,15 @@ class TestAccountRolePermissions:
owner_account = Account(name="Owner", email="owner@example.com")
owner_account.role = TenantAccountRole.OWNER
# Act & Assert
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert admin_account.is_admin
assert not owner_account.is_admin
assert admin_account.is_admin
assert not owner_account.is_admin
def test_is_admin_with_rbac_enabled(self):
def test_is_admin_with_rbac_enabled(self, config_overrides: Callable[..., None]):
account = Account(name="Test User", email="test@example.com")
account.role = TenantAccountRole.NORMAL
with patch("models.account.dify_config.RBAC_ENABLED", True):
assert account.is_admin
config_overrides(RBAC_ENABLED=True)
assert account.is_admin
def test_has_edit_permission_with_editing_roles(self):
"""Test has_edit_permission property with roles that have edit permission."""
@@ -481,16 +481,14 @@ class TestAccountRolePermissions:
account = Account(name="Test User", email=f"test_{role}@example.com")
account.role = role
# Act & Assert
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert account.has_edit_permission, f"Role {role} should have edit permission"
assert account.has_edit_permission, f"Role {role} should have edit permission"
def test_has_edit_permission_with_rbac_enabled(self):
def test_has_edit_permission_with_rbac_enabled(self, config_overrides: Callable[..., None]):
account = Account(name="Test User", email="test@example.com")
account.role = TenantAccountRole.NORMAL
with patch("models.account.dify_config.RBAC_ENABLED", True):
assert account.has_edit_permission
config_overrides(RBAC_ENABLED=True)
assert account.has_edit_permission
def test_has_edit_permission_without_editing_roles(self):
"""Test has_edit_permission property with roles that don't have edit permission."""
@@ -504,9 +502,7 @@ class TestAccountRolePermissions:
account = Account(name="Test User", email=f"test_{role}@example.com")
account.role = role
# Act & Assert
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert not account.has_edit_permission, f"Role {role} should not have edit permission"
assert not account.has_edit_permission, f"Role {role} should not have edit permission"
def test_is_dataset_editor_property(self):
"""Test is_dataset_editor property."""
@@ -522,22 +518,19 @@ class TestAccountRolePermissions:
account = Account(name="Test User", email=f"test_{role}@example.com")
account.role = role
# Act & Assert
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert account.is_dataset_editor, f"Role {role} should have dataset edit permission"
assert account.is_dataset_editor, f"Role {role} should have dataset edit permission"
# Test normal role doesn't have dataset edit permission
normal_account = Account(name="Normal User", email="normal@example.com")
normal_account.role = TenantAccountRole.NORMAL
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert not normal_account.is_dataset_editor
assert not normal_account.is_dataset_editor
def test_is_dataset_editor_with_rbac_enabled(self):
def test_is_dataset_editor_with_rbac_enabled(self, config_overrides: Callable[..., None]):
account = Account(name="Test User", email="test@example.com")
account.role = TenantAccountRole.NORMAL
with patch("models.account.dify_config.RBAC_ENABLED", True):
assert account.is_dataset_editor
config_overrides(RBAC_ENABLED=True)
assert account.is_dataset_editor
def test_is_dataset_operator_property(self):
"""Test is_dataset_operator property."""
@@ -548,17 +541,15 @@ class TestAccountRolePermissions:
normal_account = Account(name="Normal User", email="normal@example.com")
normal_account.role = TenantAccountRole.NORMAL
# Act & Assert
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert dataset_operator.is_dataset_operator
assert not normal_account.is_dataset_operator
assert dataset_operator.is_dataset_operator
assert not normal_account.is_dataset_operator
def test_is_dataset_operator_with_rbac_enabled(self):
def test_is_dataset_operator_with_rbac_enabled(self, config_overrides: Callable[..., None]):
account = Account(name="Test User", email="test@example.com")
account.role = TenantAccountRole.NORMAL
with patch("models.account.dify_config.RBAC_ENABLED", True):
assert account.is_dataset_operator
config_overrides(RBAC_ENABLED=True)
assert account.is_dataset_operator
def test_current_role_property(self):
"""Test current_role property."""
@@ -115,8 +115,10 @@ def _unexpected_timer(interval: float, function: Callable[[], bool]) -> _FakeTim
class TestBuildStreamingTaskOnSubscribe:
def test_streams_starts_only_when_hook_is_invoked_without_creating_timer(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(ags_module.dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "streams")
def test_streams_starts_only_when_hook_is_invoked_without_creating_timer(
self, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]
):
config_overrides(PUBSUB_REDIS_CHANNEL_TYPE="streams")
monkeypatch.setattr(ags_module.threading, "Timer", _unexpected_timer)
called: list[int] = []
@@ -133,8 +135,9 @@ class TestBuildStreamingTaskOnSubscribe:
self,
monkeypatch: pytest.MonkeyPatch,
channel_type: str,
config_overrides: Callable[..., None],
):
monkeypatch.setattr(ags_module.dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", channel_type)
config_overrides(PUBSUB_REDIS_CHANNEL_TYPE=channel_type)
timers: list[_FakeTimer] = []
def build_timer(interval: float, function: Callable[[], bool]) -> _FakeTimer:
@@ -157,8 +160,10 @@ class TestBuildStreamingTaskOnSubscribe:
assert called == [1]
assert timers[0].cancelled is True
def test_pubsub_fallback_starts_task_if_hook_is_never_invoked(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(ags_module.dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "pubsub")
def test_pubsub_fallback_starts_task_if_hook_is_never_invoked(
self, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]
):
config_overrides(PUBSUB_REDIS_CHANNEL_TYPE="pubsub")
timers: list[_FakeTimer] = []
def build_timer(interval: float, function: Callable[[], bool]) -> _FakeTimer:
@@ -174,8 +179,10 @@ class TestBuildStreamingTaskOnSubscribe:
on_subscribe()
assert called == [1]
def test_streams_retries_after_enqueue_failure(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(ags_module.dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "streams")
def test_streams_retries_after_enqueue_failure(
self, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]
):
config_overrides(PUBSUB_REDIS_CHANNEL_TYPE="streams")
monkeypatch.setattr(ags_module.threading, "Timer", _unexpected_timer)
call_count = 0
@@ -191,8 +198,10 @@ class TestBuildStreamingTaskOnSubscribe:
on_subscribe()
assert call_count == 2
def test_concurrent_subscribe_only_starts_once(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(ags_module.dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "streams")
def test_concurrent_subscribe_only_starts_once(
self, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]
):
config_overrides(PUBSUB_REDIS_CHANNEL_TYPE="streams")
monkeypatch.setattr(ags_module.threading, "Timer", _unexpected_timer)
call_count = 0
@@ -213,33 +222,28 @@ class TestBuildStreamingTaskOnSubscribe:
# _get_max_active_requests
# ---------------------------------------------------------------------------
class TestGetMaxActiveRequests:
def test_both_zero_returns_zero(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(ags_module.dify_config, "APP_MAX_ACTIVE_REQUESTS", 0)
monkeypatch.setattr(ags_module.dify_config, "APP_DEFAULT_ACTIVE_REQUESTS", 0)
def test_both_zero_returns_zero(self, config_overrides: Callable[..., None]):
config_overrides(APP_MAX_ACTIVE_REQUESTS=0, APP_DEFAULT_ACTIVE_REQUESTS=0)
app = _make_app(AppMode.CHAT, max_active_requests=0)
assert AppGenerateService._get_max_active_requests(app) == 0
def test_app_limit_only(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(ags_module.dify_config, "APP_MAX_ACTIVE_REQUESTS", 0)
monkeypatch.setattr(ags_module.dify_config, "APP_DEFAULT_ACTIVE_REQUESTS", 0)
def test_app_limit_only(self, config_overrides: Callable[..., None]):
config_overrides(APP_MAX_ACTIVE_REQUESTS=0, APP_DEFAULT_ACTIVE_REQUESTS=0)
app = _make_app(AppMode.CHAT, max_active_requests=5)
assert AppGenerateService._get_max_active_requests(app) == 5
def test_config_limit_only(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(ags_module.dify_config, "APP_MAX_ACTIVE_REQUESTS", 10)
monkeypatch.setattr(ags_module.dify_config, "APP_DEFAULT_ACTIVE_REQUESTS", 0)
def test_config_limit_only(self, config_overrides: Callable[..., None]):
config_overrides(APP_MAX_ACTIVE_REQUESTS=10, APP_DEFAULT_ACTIVE_REQUESTS=0)
app = _make_app(AppMode.CHAT, max_active_requests=0)
assert AppGenerateService._get_max_active_requests(app) == 10
def test_both_non_zero_returns_min(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(ags_module.dify_config, "APP_MAX_ACTIVE_REQUESTS", 20)
monkeypatch.setattr(ags_module.dify_config, "APP_DEFAULT_ACTIVE_REQUESTS", 0)
def test_both_non_zero_returns_min(self, config_overrides: Callable[..., None]):
config_overrides(APP_MAX_ACTIVE_REQUESTS=20, APP_DEFAULT_ACTIVE_REQUESTS=0)
app = _make_app(AppMode.CHAT, max_active_requests=5)
assert AppGenerateService._get_max_active_requests(app) == 5
def test_default_active_requests_used_when_app_has_none(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(ags_module.dify_config, "APP_MAX_ACTIVE_REQUESTS", 0)
monkeypatch.setattr(ags_module.dify_config, "APP_DEFAULT_ACTIVE_REQUESTS", 15)
def test_default_active_requests_used_when_app_has_none(self, config_overrides: Callable[..., None]):
config_overrides(APP_MAX_ACTIVE_REQUESTS=0, APP_DEFAULT_ACTIVE_REQUESTS=15)
app = _make_app(AppMode.CHAT, max_active_requests=0)
assert AppGenerateService._get_max_active_requests(app) == 15
@@ -251,8 +255,8 @@ class TestGenerate:
"""Tests for AppGenerateService.generate covering each mode."""
@pytest.fixture(autouse=True)
def _common(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(ags_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
def _common(self, mocker: MockerFixture, config_overrides: Callable[..., None]):
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY)
mocker.patch("services.app_generate_service.RateLimit", _DummyRateLimit)
# Prevent AppExecutionParams.new from touching real models via isinstance
mocker.patch(
@@ -403,7 +407,8 @@ class TestGenerate:
retrieve_spy.assert_not_called()
# -- ADVANCED_CHAT streaming --------------------------------------------
def test_advanced_chat_streaming(self, mocker: MockerFixture):
def test_advanced_chat_streaming(self, mocker: MockerFixture, config_overrides: Callable[..., None]):
config_overrides(PUBSUB_REDIS_CHANNEL_TYPE="streams")
workflow = _make_workflow()
mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow)
mocker.patch(
@@ -463,7 +468,8 @@ class TestGenerate:
assert call_kwargs["pause_state_config"].state_owner_user_id == "owner-id"
# -- WORKFLOW streaming -------------------------------------------------
def test_workflow_streaming(self, mocker: MockerFixture):
def test_workflow_streaming(self, mocker: MockerFixture, config_overrides: Callable[..., None]):
config_overrides(PUBSUB_REDIS_CHANNEL_TYPE="streams")
workflow = _make_workflow()
mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow)
mocker.patch(
@@ -514,15 +520,15 @@ class TestGenerate:
# ---------------------------------------------------------------------------
class TestGenerateBilling:
@pytest.fixture(autouse=True)
def _common(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch):
def _common(self, mocker: MockerFixture):
mocker.patch("services.app_generate_service.RateLimit", _DummyRateLimit)
mocker.patch(
"services.app_generate_service.rate_limit_context",
_noop_rate_limit_context,
)
def test_cloud_edition_consumes_quota(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(ags_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD)
def test_cloud_edition_consumes_quota(self, mocker: MockerFixture, config_overrides: Callable[..., None]):
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
quota_charge = MagicMock()
reserve_mock = mocker.patch(
"services.app_generate_service.QuotaService.reserve",
@@ -549,12 +555,12 @@ class TestGenerateBilling:
quota_charge.commit.assert_called_once()
def test_billing_quota_exceeded_raises_rate_limit_error(
self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
self, mocker: MockerFixture, config_overrides: Callable[..., None]
):
from services.errors.app import QuotaExceededError
from services.errors.llm import InvokeRateLimitError
monkeypatch.setattr(ags_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD)
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
mocker.patch(
"services.app_generate_service.QuotaService.reserve",
side_effect=QuotaExceededError(feature="workflow", tenant_id="t", required=1),
@@ -570,8 +576,10 @@ class TestGenerateBilling:
session=MagicMock(),
)
def test_exception_refunds_quota_and_exits_rate_limit(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(ags_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD)
def test_exception_refunds_quota_and_exits_rate_limit(
self, mocker: MockerFixture, config_overrides: Callable[..., None]
):
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
quota_charge = MagicMock()
mocker.patch(
"services.app_generate_service.QuotaService.reserve",
@@ -598,10 +606,10 @@ class TestGenerateBilling:
quota_charge.refund.assert_called_once()
def test_rate_limit_exit_called_in_finally_for_blocking(
self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
self, mocker: MockerFixture, config_overrides: Callable[..., None]
):
"""For non-streaming (blocking) calls, rate_limit.exit should be called in finally."""
monkeypatch.setattr(ags_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY)
exit_calls: list[str] = []
@@ -630,8 +638,8 @@ class TestGenerateBilling:
# exit is called in finally block for non-streaming
assert exit_calls == ["dummy-request-id"]
def test_blocking_failure_exits_rate_limit_once(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(ags_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD)
def test_blocking_failure_exits_rate_limit_once(self, mocker: MockerFixture, config_overrides: Callable[..., None]):
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
quota_charge = MagicMock()
mocker.patch(
"services.app_generate_service.QuotaService.reserve",
@@ -662,8 +670,10 @@ class TestGenerateBilling:
quota_charge.refund.assert_called_once()
assert exit_calls == ["dummy-request-id"]
def test_streaming_failure_exits_rate_limit_once(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(ags_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD)
def test_streaming_failure_exits_rate_limit_once(
self, mocker: MockerFixture, config_overrides: Callable[..., None]
):
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
quota_charge = MagicMock()
mocker.patch(
"services.app_generate_service.QuotaService.reserve",