diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml
index 00dae047e04..d44b89c95d9 100644
--- a/.github/workflows/autofix.yml
+++ b/.github/workflows/autofix.yml
@@ -151,5 +151,9 @@ jobs:
run: |
vp exec eslint --concurrency=2 --prune-suppressions --quiet || true
+ - name: Prune unused i18n
+ if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
+ run: vp run dify-web#i18n:prune-unused --write
+
- if: github.event_name != 'merge_group'
uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4
diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml
index c80e1f3d2be..623703c6d35 100644
--- a/.github/workflows/style.yml
+++ b/.github/workflows/style.yml
@@ -109,6 +109,10 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
run: vp run knip:production
+ - name: Web production unused declarations check
+ if: steps.changed-files.outputs.any_changed == 'true'
+ run: vp run knip:production-unused-check
+
ts-common-style:
name: TS Common
runs-on: depot-ubuntu-24.04
diff --git a/api/controllers/console/workspace/rbac.py b/api/controllers/console/workspace/rbac.py
index f672833061a..c3a3420b908 100644
--- a/api/controllers/console/workspace/rbac.py
+++ b/api/controllers/console/workspace/rbac.py
@@ -201,21 +201,23 @@ def _legacy_workspace_roles(
This keeps the new `/rbac/roles` endpoint compatible with the original
Dify role model when enterprise RBAC is disabled.
"""
-
- legacy_roles = [
- svc.RBACRole(
- id=role_name,
- tenant_id="",
- type=svc.RBACRoleType.WORKSPACE.value,
- category="global_system_default",
- name=role_name,
- description="",
- is_builtin=True,
- permission_keys=list(dict.fromkeys(_LEGACY_ROLE_PERMISSION_KEYS[role_name])),
- role_tag="owner" if role_name == "owner" else "",
+ legacy_roles = []
+ for role_name in ("owner", "admin", "editor", "normal", "dataset_operator"):
+ if not dify_config.DATASET_OPERATOR_ENABLED and role_name == "dataset_operator":
+ continue
+ legacy_roles.append(
+ svc.RBACRole(
+ id=role_name,
+ tenant_id="",
+ type=svc.RBACRoleType.WORKSPACE.value,
+ category="global_system_default",
+ name=role_name,
+ description="",
+ is_builtin=True,
+ permission_keys=list(dict.fromkeys(_LEGACY_ROLE_PERMISSION_KEYS[role_name])),
+ role_tag="owner" if role_name == "owner" else "",
+ )
)
- for role_name in ("owner", "admin", "editor", "normal", "dataset_operator")
- ]
if not include_owner:
legacy_roles = [r for r in legacy_roles if r.name != "owner"]
diff --git a/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_tencent_trace.py b/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_tencent_trace.py
index 54524b09ca6..2bdf7cd1862 100644
--- a/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_tencent_trace.py
+++ b/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_tencent_trace.py
@@ -195,16 +195,16 @@ class TestTencentDataTrace:
mock_proc.assert_called_once_with(trace_info, 123)
mock_dur.assert_called_once_with(trace_info)
- def test_workflow_trace_exception(self, tencent_data_trace):
+ def test_workflow_trace_exception(self, tencent_data_trace, caplog):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.workflow_run_id = "run-id"
with patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_trace_id", side_effect=Exception("error")
):
- with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
+ with caplog.at_level(logging.ERROR):
tencent_data_trace.workflow_trace(trace_info)
- mock_log.assert_called_once_with("[Tencent APM] Failed to process workflow trace")
+ assert "[Tencent APM] Failed to process workflow trace" in caplog.text
def test_message_trace(self, tencent_data_trace, mock_trace_utils, mock_span_builder):
trace_info = MagicMock(spec=MessageTraceInfo)
@@ -228,15 +228,15 @@ class TestTencentDataTrace:
mock_metrics.assert_called_once_with(trace_info)
mock_dur.assert_called_once_with(trace_info)
- def test_message_trace_exception(self, tencent_data_trace):
+ def test_message_trace_exception(self, tencent_data_trace, caplog):
trace_info = MagicMock(spec=MessageTraceInfo)
with patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_trace_id", side_effect=Exception("error")
):
- with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
+ with caplog.at_level(logging.ERROR):
tencent_data_trace.message_trace(trace_info)
- mock_log.assert_called_once_with("[Tencent APM] Failed to process message trace")
+ assert "[Tencent APM] Failed to process message trace" in caplog.text
def test_tool_trace(self, tencent_data_trace, mock_trace_utils, mock_span_builder):
trace_info = MagicMock(spec=ToolTraceInfo)
@@ -259,16 +259,16 @@ class TestTencentDataTrace:
tencent_data_trace.tool_trace(trace_info)
tencent_data_trace.trace_client.add_span.assert_not_called()
- def test_tool_trace_exception(self, tencent_data_trace):
+ def test_tool_trace_exception(self, tencent_data_trace, caplog):
trace_info = MagicMock(spec=ToolTraceInfo)
trace_info.message_id = "msg-id"
with patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_span_id", side_effect=Exception("error")
):
- with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
+ with caplog.at_level(logging.ERROR):
tencent_data_trace.tool_trace(trace_info)
- mock_log.assert_called_once_with("[Tencent APM] Failed to process tool trace")
+ assert "[Tencent APM] Failed to process tool trace" in caplog.text
def test_dataset_retrieval_trace(self, tencent_data_trace, mock_trace_utils, mock_span_builder):
trace_info = MagicMock(spec=DatasetRetrievalTraceInfo)
@@ -291,29 +291,30 @@ class TestTencentDataTrace:
tencent_data_trace.dataset_retrieval_trace(trace_info)
tencent_data_trace.trace_client.add_span.assert_not_called()
- def test_dataset_retrieval_trace_exception(self, tencent_data_trace):
+ def test_dataset_retrieval_trace_exception(self, tencent_data_trace, caplog):
trace_info = MagicMock(spec=DatasetRetrievalTraceInfo)
trace_info.message_id = "msg-id"
with patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_span_id", side_effect=Exception("error")
):
- with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
+ with caplog.at_level(logging.ERROR):
tencent_data_trace.dataset_retrieval_trace(trace_info)
- mock_log.assert_called_once_with("[Tencent APM] Failed to process dataset retrieval trace")
+ assert "[Tencent APM] Failed to process dataset retrieval trace" in caplog.text
- def test_suggested_question_trace(self, tencent_data_trace):
+ def test_suggested_question_trace(self, tencent_data_trace, caplog):
trace_info = MagicMock(spec=SuggestedQuestionTraceInfo)
- with patch("dify_trace_tencent.tencent_trace.logger.info") as mock_log:
+ with caplog.at_level(logging.INFO):
tencent_data_trace.suggested_question_trace(trace_info)
- mock_log.assert_called_once_with("[Tencent APM] Processing suggested question trace")
+ assert "[Tencent APM] Processing suggested question trace" in caplog.text
- def test_suggested_question_trace_exception(self, tencent_data_trace):
+ def test_suggested_question_trace_exception(self, tencent_data_trace, monkeypatch, caplog):
trace_info = MagicMock(spec=SuggestedQuestionTraceInfo)
- with patch("dify_trace_tencent.tencent_trace.logger.info", side_effect=Exception("error")):
- with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
- tencent_data_trace.suggested_question_trace(trace_info)
- mock_log.assert_called_once_with("[Tencent APM] Failed to process suggested question trace")
+ target_logger = logging.getLogger("dify_trace_tencent.tencent_trace")
+ monkeypatch.setattr(target_logger, "info", MagicMock(side_effect=Exception("error")))
+ with caplog.at_level(logging.ERROR):
+ tencent_data_trace.suggested_question_trace(trace_info)
+ assert "[Tencent APM] Failed to process suggested question trace" in caplog.text
def test_process_workflow_nodes(self, tencent_data_trace, mock_trace_utils):
trace_info = MagicMock(spec=WorkflowTraceInfo)
@@ -335,7 +336,7 @@ class TestTencentDataTrace:
assert tencent_data_trace.trace_client.add_span.call_count == 2
mock_metrics.assert_called_once_with(node1)
- def test_process_workflow_nodes_node_exception(self, tencent_data_trace, mock_trace_utils):
+ def test_process_workflow_nodes_node_exception(self, tencent_data_trace, mock_trace_utils, caplog):
trace_info = MagicMock(spec=WorkflowTraceInfo)
mock_trace_utils.convert_to_span_id.return_value = 111
@@ -344,18 +345,17 @@ class TestTencentDataTrace:
with patch.object(tencent_data_trace, "_get_workflow_node_executions", return_value=[node]):
with patch.object(tencent_data_trace, "_build_workflow_node_span", side_effect=Exception("node error")):
- with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
+ with caplog.at_level(logging.ERROR):
tencent_data_trace._process_workflow_nodes(trace_info, 123)
- # The exception should be caught by the outer handler since convert_to_span_id is called first
- mock_log.assert_called_once_with("[Tencent APM] Failed to process workflow nodes")
+ assert "[Tencent APM] Failed to process workflow nodes" in caplog.text
- def test_process_workflow_nodes_exception(self, tencent_data_trace, mock_trace_utils):
+ def test_process_workflow_nodes_exception(self, tencent_data_trace, mock_trace_utils, caplog):
trace_info = MagicMock(spec=WorkflowTraceInfo)
mock_trace_utils.convert_to_span_id.side_effect = Exception("outer error")
- with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
+ with caplog.at_level(logging.ERROR):
tencent_data_trace._process_workflow_nodes(trace_info, 123)
- mock_log.assert_called_once_with("[Tencent APM] Failed to process workflow nodes")
+ assert "[Tencent APM] Failed to process workflow nodes" in caplog.text
def test_build_workflow_node_span(self, tencent_data_trace, mock_span_builder):
trace_info = MagicMock(spec=WorkflowTraceInfo)
@@ -377,16 +377,16 @@ class TestTencentDataTrace:
assert result == "span"
builder_method.assert_called_once_with(123, 456, trace_info, node)
- def test_build_workflow_node_span_exception(self, tencent_data_trace, mock_span_builder):
+ def test_build_workflow_node_span_exception(self, tencent_data_trace, mock_span_builder, caplog):
node = MagicMock(spec=WorkflowNodeExecution)
node.node_type = BuiltinNodeTypes.LLM
node.id = "n1"
mock_span_builder.build_workflow_llm_span.side_effect = Exception("error")
- with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log:
+ with caplog.at_level(logging.DEBUG):
result = tencent_data_trace._build_workflow_node_span(node, 123, MagicMock(), 456)
- assert result is None
- mock_log.assert_called_once()
+ assert result is None
+ assert len([r for r in caplog.records if r.levelno == logging.DEBUG]) >= 1
def test_get_workflow_node_executions(self, tencent_data_trace):
trace_info = MagicMock(spec=WorkflowTraceInfo)
@@ -419,16 +419,16 @@ class TestTencentDataTrace:
assert results == mock_executions
account.set_tenant_id.assert_called_once_with("tenant-1")
- def test_get_workflow_node_executions_no_app_id(self, tencent_data_trace):
+ def test_get_workflow_node_executions_no_app_id(self, tencent_data_trace, caplog):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.metadata = {}
- with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
+ with caplog.at_level(logging.ERROR):
results = tencent_data_trace._get_workflow_node_executions(trace_info)
- assert results == []
- mock_log.assert_called_once()
+ assert results == []
+ assert len([r for r in caplog.records if r.levelno == logging.ERROR]) >= 1
- def test_get_workflow_node_executions_app_not_found(self, tencent_data_trace):
+ def test_get_workflow_node_executions_app_not_found(self, tencent_data_trace, caplog):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.metadata = {"app_id": "app-1"}
@@ -439,10 +439,10 @@ class TestTencentDataTrace:
session = mock_session_ctx.return_value.__enter__.return_value
session.scalar.return_value = None
- with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
+ with caplog.at_level(logging.ERROR):
results = tencent_data_trace._get_workflow_node_executions(trace_info)
- assert results == []
- mock_log.assert_called_once()
+ assert results == []
+ assert len([r for r in caplog.records if r.levelno == logging.ERROR]) >= 1
def test_get_user_id_workflow(self, tencent_data_trace):
trace_info = MagicMock(spec=WorkflowTraceInfo)
@@ -471,16 +471,16 @@ class TestTencentDataTrace:
user_id = tencent_data_trace._get_user_id(trace_info)
assert user_id == "anonymous"
- def test_get_user_id_exception(self, tencent_data_trace):
+ def test_get_user_id_exception(self, tencent_data_trace, caplog):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.tenant_id = "t"
trace_info.metadata = {"user_id": "u"}
with patch("dify_trace_tencent.tencent_trace.sessionmaker", side_effect=Exception("error")):
- with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
+ with caplog.at_level(logging.ERROR):
user_id = tencent_data_trace._get_user_id(trace_info)
- assert user_id == "unknown"
- mock_log.assert_called_once_with("[Tencent APM] Failed to get user ID")
+ assert user_id == "unknown"
+ assert "[Tencent APM] Failed to get user ID" in caplog.text
def test_record_llm_metrics_usage_in_process_data(self, tencent_data_trace):
node = MagicMock(spec=WorkflowNodeExecution)
@@ -514,14 +514,14 @@ class TestTencentDataTrace:
tencent_data_trace.trace_client.record_llm_duration.assert_called_once()
tencent_data_trace.trace_client.record_token_usage.assert_called_once()
- def test_record_llm_metrics_exception(self, tencent_data_trace):
+ def test_record_llm_metrics_exception(self, tencent_data_trace, caplog):
node = MagicMock(spec=WorkflowNodeExecution)
node.process_data = None
node.outputs = None
- with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log:
+ with caplog.at_level(logging.DEBUG):
tencent_data_trace._record_llm_metrics(node)
- # Should not crash
+ # Should not crash
def test_record_message_llm_metrics(self, tencent_data_trace):
trace_info = MagicMock(spec=MessageTraceInfo)
@@ -553,13 +553,13 @@ class TestTencentDataTrace:
tencent_data_trace._record_message_llm_metrics(trace_info)
tencent_data_trace.trace_client.record_llm_duration.assert_called_once()
- def test_record_message_llm_metrics_exception(self, tencent_data_trace):
+ def test_record_message_llm_metrics_exception(self, tencent_data_trace, caplog):
trace_info = MagicMock(spec=MessageTraceInfo)
trace_info.metadata = None
- with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log:
+ with caplog.at_level(logging.DEBUG):
tencent_data_trace._record_message_llm_metrics(trace_info)
- # Should not crash
+ # Should not crash
def test_record_workflow_trace_duration(self, tencent_data_trace):
trace_info = MagicMock(spec=WorkflowTraceInfo)
@@ -605,11 +605,11 @@ class TestTencentDataTrace:
attributes = kwargs["attributes"] if "attributes" in kwargs else args[1] if len(args) > 1 else {}
assert attributes["has_conversation"] == "false"
- def test_record_workflow_trace_duration_exception(self, tencent_data_trace):
+ def test_record_workflow_trace_duration_exception(self, tencent_data_trace, caplog):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.start_time = MagicMock() # This might cause total_seconds() to fail if not mocked right
- with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log:
+ with caplog.at_level(logging.DEBUG):
tencent_data_trace._record_workflow_trace_duration(trace_info)
def test_record_message_trace_duration(self, tencent_data_trace):
@@ -627,11 +627,11 @@ class TestTencentDataTrace:
2.0, {"conversation_mode": "chat", "stream": "true"}
)
- def test_record_message_trace_duration_exception(self, tencent_data_trace):
+ def test_record_message_trace_duration_exception(self, tencent_data_trace, caplog):
trace_info = MagicMock(spec=MessageTraceInfo)
trace_info.start_time = None
- with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log:
+ with caplog.at_level(logging.DEBUG):
tencent_data_trace._record_message_trace_duration(trace_info)
def test_close(self, tencent_data_trace):
@@ -647,11 +647,11 @@ class TestTencentDataTrace:
client.shutdown.assert_called_once()
- def test_close_exception(self, tencent_data_trace):
+ def test_close_exception(self, tencent_data_trace, caplog):
tencent_data_trace.trace_client.shutdown.side_effect = Exception("error")
- with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
+ with caplog.at_level(logging.ERROR):
tencent_data_trace.close()
- mock_log.assert_called_once_with("[Tencent APM] Failed to shutdown trace client during cleanup")
+ assert "[Tencent APM] Failed to shutdown trace client during cleanup" in caplog.text
def test_close_handles_async_shutdown_mock(self, tencent_data_trace):
shutdown = AsyncMock()
diff --git a/api/providers/vdb/vdb-lindorm/src/dify_vdb_lindorm/lindorm_vector.py b/api/providers/vdb/vdb-lindorm/src/dify_vdb_lindorm/lindorm_vector.py
index 0e37cf6e4a3..d066aafff94 100644
--- a/api/providers/vdb/vdb-lindorm/src/dify_vdb_lindorm/lindorm_vector.py
+++ b/api/providers/vdb/vdb-lindorm/src/dify_vdb_lindorm/lindorm_vector.py
@@ -113,7 +113,7 @@ class LindormVectorStore(BaseVector):
)
def _bulk_with_retry(actions):
try:
- response = self._client.bulk(actions, timeout=timeout)
+ response = self._client.bulk(body=actions, timeout=timeout)
if response["errors"]:
error_items = [item for item in response["items"] if "error" in item["index"]]
error_msg = f"Bulk indexing had {len(error_items)} errors"
@@ -231,7 +231,7 @@ class LindormVectorStore(BaseVector):
routing_filter_query = {
"query": {"bool": {"must": [{"term": {f"{ROUTING_FIELD}.keyword": self._routing}}]}}
}
- self._client.delete_by_query(self._collection_name, body=routing_filter_query)
+ self._client.delete_by_query(index=self._collection_name, body=routing_filter_query)
self.refresh()
else:
if self._client.indices.exists(index=self._collection_name):
diff --git a/api/providers/vdb/vdb-lindorm/tests/unit_tests/test_lindorm_vector.py b/api/providers/vdb/vdb-lindorm/tests/unit_tests/test_lindorm_vector.py
index 4a408d1b101..182a4f4ea97 100644
--- a/api/providers/vdb/vdb-lindorm/tests/unit_tests/test_lindorm_vector.py
+++ b/api/providers/vdb/vdb-lindorm/tests/unit_tests/test_lindorm_vector.py
@@ -127,7 +127,7 @@ def test_create_refresh_and_add_texts_success(lindorm_module, monkeypatch: pytes
vector.add_texts(docs, embeddings, batch_size=2, timeout=9)
assert vector._client.bulk.call_count == 2
- actions = vector._client.bulk.call_args_list[0].args[0]
+ actions = vector._client.bulk.call_args_list[0].kwargs["body"]
assert actions[0]["index"]["routing"] == "route"
assert actions[1][lindorm_module.ROUTING_FIELD] == "route"
vector.refresh()
diff --git a/api/services/enterprise/rbac_service.py b/api/services/enterprise/rbac_service.py
index b5585932b29..af6f79948d7 100644
--- a/api/services/enterprise/rbac_service.py
+++ b/api/services/enterprise/rbac_service.py
@@ -379,6 +379,9 @@ _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
"snippets.create_and_modify",
"tool.manage",
"snippets.create_and_modify",
+ "billing.view",
+ "billing.subscription.manage",
+ "billing.manage",
]
_LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
@@ -386,6 +389,9 @@ _LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
"plugin.install",
"credential.use",
"app_library.access",
+ "billing.view",
+ "billing.subscription.manage",
+ "billing.manage",
]
_LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [
@@ -834,6 +840,7 @@ class RBACService:
options: ListOption | None = None,
) -> Paginated[RBACRole]:
params = (options or ListOption()).to_params({"include_owner": include_owner})
+ params["dataset_operator_enabled"] = dify_config.DATASET_OPERATOR_ENABLED
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/roles",
diff --git a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py
index 7a4f583ba83..2c845673cd1 100644
--- a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py
+++ b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py
@@ -9,6 +9,7 @@ This module tests the core authentication endpoints including:
"""
import base64
+import logging
from unittest.mock import ANY, MagicMock, Mock, patch
import pytest
@@ -191,7 +192,7 @@ class TestLoginApi:
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@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(self, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask):
+ def test_login_fails_when_rate_limited(self, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask, caplog):
"""
Test login rejection when rate limit is exceeded.
@@ -204,22 +205,24 @@ class TestLoginApi:
mock_get_invitation.return_value = None
# Act & Assert
- with patch("controllers.console.auth.login.logger.warning") as mock_log_warning:
- with app.test_request_context(
- "/login", method="POST", json={"email": "test@example.com", "password": encode_password("password")}
- ):
- login_api = LoginApi()
- with pytest.raises(EmailPasswordLoginLimitError):
- login_api.post()
+ with app.test_request_context(
+ "/login", method="POST", json={"email": "test@example.com", "password": encode_password("password")}
+ ):
+ login_api = LoginApi()
+ with pytest.raises(EmailPasswordLoginLimitError):
+ login_api.post()
- assert mock_log_warning.call_count == 1
- assert mock_log_warning.call_args.args[1] == "test@example.com"
- assert mock_log_warning.call_args.args[2] == LoginFailureReason.LOGIN_RATE_LIMITED
+ warn_records = [
+ r for r in caplog.records if r.name == "controllers.console.auth.login" and r.levelno == logging.WARNING
+ ]
+ assert len(warn_records) == 1
+ assert warn_records[0].args[0] == "test@example.com"
+ assert warn_records[0].args[1] == LoginFailureReason.LOGIN_RATE_LIMITED
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", True)
@patch("controllers.console.auth.login.BillingService.is_email_in_freeze")
- def test_login_fails_when_account_frozen(self, mock_is_frozen, mock_db, app: Flask):
+ def test_login_fails_when_account_frozen(self, mock_is_frozen, mock_db, app: Flask, caplog):
"""
Test login rejection for frozen accounts.
@@ -231,17 +234,19 @@ class TestLoginApi:
mock_is_frozen.return_value = True
# Act & Assert
- with patch("controllers.console.auth.login.logger.warning") as mock_log_warning:
- with app.test_request_context(
- "/login", method="POST", json={"email": "frozen@example.com", "password": encode_password("password")}
- ):
- login_api = LoginApi()
- with pytest.raises(AccountInFreezeError):
- login_api.post()
+ with app.test_request_context(
+ "/login", method="POST", json={"email": "frozen@example.com", "password": encode_password("password")}
+ ):
+ login_api = LoginApi()
+ with pytest.raises(AccountInFreezeError):
+ login_api.post()
- assert mock_log_warning.call_count == 1
- assert mock_log_warning.call_args.args[1] == "frozen@example.com"
- assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_IN_FREEZE
+ warn_records = [
+ r for r in caplog.records if r.name == "controllers.console.auth.login" and r.levelno == logging.WARNING
+ ]
+ assert len(warn_records) == 1
+ assert warn_records[0].args[0] == "frozen@example.com"
+ assert warn_records[0].args[1] == LoginFailureReason.ACCOUNT_IN_FREEZE
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@@ -257,6 +262,7 @@ class TestLoginApi:
mock_is_rate_limit,
mock_db,
app: Flask,
+ caplog,
):
"""
Test login failure with invalid credentials.
@@ -272,20 +278,22 @@ class TestLoginApi:
mock_authenticate.side_effect = AccountPasswordError("Invalid password")
# Act & Assert
- with patch("controllers.console.auth.login.logger.warning") as mock_log_warning:
- with app.test_request_context(
- "/login",
- method="POST",
- json={"email": "test@example.com", "password": encode_password("WrongPass123!")},
- ):
- login_api = LoginApi()
- with pytest.raises(AuthenticationFailedError):
- login_api.post()
+ with app.test_request_context(
+ "/login",
+ method="POST",
+ json={"email": "test@example.com", "password": encode_password("WrongPass123!")},
+ ):
+ login_api = LoginApi()
+ with pytest.raises(AuthenticationFailedError):
+ login_api.post()
mock_add_rate_limit.assert_called_once_with("test@example.com")
- assert mock_log_warning.call_count == 1
- assert mock_log_warning.call_args.args[1] == "test@example.com"
- assert mock_log_warning.call_args.args[2] == LoginFailureReason.INVALID_CREDENTIALS
+ warn_records = [
+ r for r in caplog.records if r.name == "controllers.console.auth.login" and r.levelno == logging.WARNING
+ ]
+ assert len(warn_records) == 1
+ assert warn_records[0].args[0] == "test@example.com"
+ assert warn_records[0].args[1] == LoginFailureReason.INVALID_CREDENTIALS
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@@ -293,7 +301,7 @@ class TestLoginApi:
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.login.AccountService.authenticate")
def test_login_fails_for_banned_account(
- self, mock_authenticate, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask
+ self, mock_authenticate, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask, caplog
):
"""
Test login rejection for banned accounts.
@@ -308,19 +316,21 @@ class TestLoginApi:
mock_authenticate.side_effect = AccountLoginError("Account is banned")
# Act & Assert
- with patch("controllers.console.auth.login.logger.warning") as mock_log_warning:
- with app.test_request_context(
- "/login",
- method="POST",
- json={"email": "banned@example.com", "password": encode_password("ValidPass123!")},
- ):
- login_api = LoginApi()
- with pytest.raises(AccountBannedError):
- login_api.post()
+ with app.test_request_context(
+ "/login",
+ method="POST",
+ json={"email": "banned@example.com", "password": encode_password("ValidPass123!")},
+ ):
+ login_api = LoginApi()
+ with pytest.raises(AccountBannedError):
+ login_api.post()
- assert mock_log_warning.call_count == 1
- assert mock_log_warning.call_args.args[1] == "banned@example.com"
- assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_BANNED
+ warn_records = [
+ r for r in caplog.records if r.name == "controllers.console.auth.login" and r.levelno == logging.WARNING
+ ]
+ assert len(warn_records) == 1
+ assert warn_records[0].args[0] == "banned@example.com"
+ assert warn_records[0].args[1] == LoginFailureReason.ACCOUNT_BANNED
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@@ -452,23 +462,26 @@ class TestLoginApi:
mock_get_token_data: MagicMock,
mock_db: MagicMock,
app: Flask,
+ caplog,
):
mock_get_token_data.return_value = {"email": "User@Example.com", "code": "123456"}
mock_get_account.side_effect = Unauthorized("Account is banned.")
- with patch("controllers.console.auth.login.logger.warning") as mock_log_warning:
- with app.test_request_context(
- "/email-code-login/validity",
- method="POST",
- json={"email": "User@Example.com", "code": encode_code("123456"), "token": "token-123"},
- ):
- with pytest.raises(AccountBannedError):
- EmailCodeLoginApi().post()
+ with app.test_request_context(
+ "/email-code-login/validity",
+ method="POST",
+ json={"email": "User@Example.com", "code": encode_code("123456"), "token": "token-123"},
+ ):
+ with pytest.raises(AccountBannedError):
+ EmailCodeLoginApi().post()
mock_revoke_token.assert_called_once_with("token-123")
- assert mock_log_warning.call_count == 1
- assert mock_log_warning.call_args.args[1] == "user@example.com"
- assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_BANNED
+ warn_records = [
+ r for r in caplog.records if r.name == "controllers.console.auth.login" and r.levelno == logging.WARNING
+ ]
+ assert len(warn_records) == 1
+ assert warn_records[0].args[0] == "user@example.com"
+ assert warn_records[0].args[1] == LoginFailureReason.ACCOUNT_BANNED
class TestLogoutApi:
diff --git a/api/tests/unit_tests/controllers/console/workspace/test_rbac.py b/api/tests/unit_tests/controllers/console/workspace/test_rbac.py
index d78bc1fc6dd..2960bfef324 100644
--- a/api/tests/unit_tests/controllers/console/workspace/test_rbac.py
+++ b/api/tests/unit_tests/controllers/console/workspace/test_rbac.py
@@ -201,10 +201,10 @@ class TestPaginationMapping:
},
]
assert response["pagination"] == {
- "total_count": 5,
+ "total_count": 4,
"per_page": 2,
"current_page": 1,
- "total_pages": 3,
+ "total_pages": 2,
}
mock_list.assert_not_called()
diff --git a/api/tests/unit_tests/controllers/web/test_web_login.py b/api/tests/unit_tests/controllers/web/test_web_login.py
index 839939367c4..bfffd5cbb2c 100644
--- a/api/tests/unit_tests/controllers/web/test_web_login.py
+++ b/api/tests/unit_tests/controllers/web/test_web_login.py
@@ -1,4 +1,5 @@
import base64
+import logging
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -16,6 +17,13 @@ def encode_code(code: str) -> str:
return base64.b64encode(code.encode("utf-8")).decode()
+def assert_login_failure_logged(caplog: pytest.LogCaptureFixture, email: str, reason: LoginFailureReason) -> None:
+ records = [record for record in caplog.records if record.name == "controllers.web.login"]
+ assert len(records) == 1
+ assert records[0].args[0] == email
+ assert records[0].args[1] == reason
+
+
@pytest.fixture
def app():
flask_app = Flask(__name__)
@@ -114,10 +122,10 @@ class TestLoginApi:
"controllers.web.login.WebAppAuthService.authenticate",
side_effect=services.errors.account.AccountLoginError(),
)
- def test_login_banned_account(self, mock_auth: MagicMock, app: Flask) -> None:
+ def test_login_banned_account(self, mock_auth: MagicMock, app: Flask, caplog: pytest.LogCaptureFixture) -> None:
from controllers.console.error import AccountBannedError
- with patch("controllers.web.login.logger.warning") as mock_log_warning:
+ with caplog.at_level(logging.WARNING, logger="controllers.web.login"):
with app.test_request_context(
"/web/login",
method="POST",
@@ -126,18 +134,16 @@ class TestLoginApi:
with pytest.raises(AccountBannedError):
LoginApi().post()
- assert mock_log_warning.call_count == 1
- assert mock_log_warning.call_args.args[1] == "user@example.com"
- assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_BANNED
+ assert_login_failure_logged(caplog, "user@example.com", LoginFailureReason.ACCOUNT_BANNED)
@patch(
"controllers.web.login.WebAppAuthService.authenticate",
side_effect=services.errors.account.AccountPasswordError(),
)
- def test_login_wrong_password(self, mock_auth: MagicMock, app: Flask) -> None:
+ def test_login_wrong_password(self, mock_auth: MagicMock, app: Flask, caplog: pytest.LogCaptureFixture) -> None:
from controllers.console.auth.error import AuthenticationFailedError
- with patch("controllers.web.login.logger.warning") as mock_log_warning:
+ with caplog.at_level(logging.WARNING, logger="controllers.web.login"):
with app.test_request_context(
"/web/login",
method="POST",
@@ -146,18 +152,16 @@ class TestLoginApi:
with pytest.raises(AuthenticationFailedError):
LoginApi().post()
- assert mock_log_warning.call_count == 1
- assert mock_log_warning.call_args.args[1] == "user@example.com"
- assert mock_log_warning.call_args.args[2] == LoginFailureReason.INVALID_CREDENTIALS
+ assert_login_failure_logged(caplog, "user@example.com", LoginFailureReason.INVALID_CREDENTIALS)
@patch(
"controllers.web.login.WebAppAuthService.authenticate",
side_effect=services.errors.account.AccountNotFoundError(),
)
- def test_login_account_not_found(self, mock_auth: MagicMock, app: Flask) -> None:
+ def test_login_account_not_found(self, mock_auth: MagicMock, app: Flask, caplog: pytest.LogCaptureFixture) -> None:
from controllers.console.auth.error import AuthenticationFailedError
- with patch("controllers.web.login.logger.warning") as mock_log_warning:
+ with caplog.at_level(logging.WARNING, logger="controllers.web.login"):
with app.test_request_context(
"/web/login",
method="POST",
@@ -166,13 +170,13 @@ class TestLoginApi:
with pytest.raises(AuthenticationFailedError):
LoginApi().post()
- assert mock_log_warning.call_count == 1
- assert mock_log_warning.call_args.args[1] == "missing@example.com"
- assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_NOT_FOUND
+ assert_login_failure_logged(caplog, "missing@example.com", LoginFailureReason.ACCOUNT_NOT_FOUND)
@patch("controllers.web.login.WebAppAuthService.get_email_code_login_data", return_value=None)
- def test_email_code_login_logs_invalid_token(self, mock_get_token_data: MagicMock, app: Flask) -> None:
- with patch("controllers.web.login.logger.warning") as mock_log_warning:
+ def test_email_code_login_logs_invalid_token(
+ self, mock_get_token_data: MagicMock, app: Flask, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ with caplog.at_level(logging.WARNING, logger="controllers.web.login"):
with app.test_request_context(
"/web/email-code-login/validity",
method="POST",
@@ -182,9 +186,7 @@ class TestLoginApi:
EmailCodeLoginApi().post()
mock_get_token_data.assert_called_once_with("token-123")
- assert mock_log_warning.call_count == 1
- assert mock_log_warning.call_args.args[1] == "user@example.com"
- assert mock_log_warning.call_args.args[2] == LoginFailureReason.INVALID_EMAIL_CODE_TOKEN
+ assert_login_failure_logged(caplog, "user@example.com", LoginFailureReason.INVALID_EMAIL_CODE_TOKEN)
@patch("controllers.web.login.WebAppAuthService.revoke_email_code_login_token")
@patch(
@@ -201,10 +203,11 @@ class TestLoginApi:
mock_get_user: MagicMock,
mock_revoke_token: MagicMock,
app: Flask,
+ caplog: pytest.LogCaptureFixture,
) -> None:
from controllers.console.error import AccountBannedError
- with patch("controllers.web.login.logger.warning") as mock_log_warning:
+ with caplog.at_level(logging.WARNING, logger="controllers.web.login"):
with app.test_request_context(
"/web/email-code-login/validity",
method="POST",
@@ -215,9 +218,7 @@ class TestLoginApi:
mock_get_token_data.assert_called_once_with("token-123")
mock_revoke_token.assert_called_once_with("token-123")
- assert mock_log_warning.call_count == 1
- assert mock_log_warning.call_args.args[1] == "user@example.com"
- assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_BANNED
+ assert_login_failure_logged(caplog, "user@example.com", LoginFailureReason.ACCOUNT_BANNED)
class TestLoginStatusApi:
diff --git a/api/tests/unit_tests/services/enterprise/test_rbac_service.py b/api/tests/unit_tests/services/enterprise/test_rbac_service.py
index 5dc68008840..ef786c944e1 100644
--- a/api/tests/unit_tests/services/enterprise/test_rbac_service.py
+++ b/api/tests/unit_tests/services/enterprise/test_rbac_service.py
@@ -86,21 +86,26 @@ class TestRoles:
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/roles"
- assert call.params == {"page_number": 2, "results_per_page": 50, "reverse": "true"}
+ assert call.params == {
+ "dataset_operator_enabled": False,
+ "page_number": 2,
+ "results_per_page": 50,
+ "reverse": "true",
+ }
assert out.pagination
assert out.pagination.total_count == 1
def test_list_omits_params_when_default(self, mock_send: MagicMock):
mock_send.return_value = {"data": [], "pagination": None}
svc.RBACService.Roles.list("tenant-1")
- assert _call_args(mock_send).params is None
+ assert _call_args(mock_send).params is not None
def test_list_forwards_include_owner(self, mock_send: MagicMock):
mock_send.return_value = {"data": [], "pagination": None}
svc.RBACService.Roles.list("tenant-1", include_owner=1)
- assert _call_args(mock_send).params == {"include_owner": 1}
+ assert _call_args(mock_send).params == {"dataset_operator_enabled": False, "include_owner": 1}
def test_list_coerces_null_permission_keys(self, mock_send: MagicMock):
mock_send.return_value = {
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index ba5b7366185..b7a6eb5f16a 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -474,11 +474,6 @@
"count": 1
}
},
- "web/app/components/app/configuration/base/var-highlight/index.tsx": {
- "react-refresh/only-export-components": {
- "count": 1
- }
- },
"web/app/components/app/configuration/config-prompt/__tests__/index.spec.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@@ -1068,34 +1063,6 @@
"count": 1
}
},
- "web/app/components/base/block-input/index.stories.tsx": {
- "no-console": {
- "count": 2
- },
- "ts/no-explicit-any": {
- "count": 1
- }
- },
- "web/app/components/base/block-input/index.tsx": {
- "jsx-a11y/click-events-have-key-events": {
- "count": 1
- },
- "jsx-a11y/no-static-element-interactions": {
- "count": 1
- },
- "react-refresh/only-export-components": {
- "count": 1
- },
- "react/no-nested-component-definitions": {
- "count": 1
- },
- "react/set-state-in-effect": {
- "count": 1
- },
- "react/static-components": {
- "count": 2
- }
- },
"web/app/components/base/carousel/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -1387,7 +1354,7 @@
},
"web/app/components/base/error-boundary/index.tsx": {
"react-refresh/only-export-components": {
- "count": 3
+ "count": 1
},
"react/jsx-no-key-after-spread": {
"count": 1
@@ -2610,11 +2577,6 @@
"count": 3
}
},
- "web/app/components/datasets/common/retrieval-method-info/index.tsx": {
- "react-refresh/only-export-components": {
- "count": 1
- }
- },
"web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/header.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@@ -3089,19 +3051,6 @@
"count": 1
}
},
- "web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.tsx": {
- "ts/no-non-null-asserted-optional-chain": {
- "count": 1
- }
- },
- "web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-state.ts": {
- "react-hooks-extra/no-direct-set-state-in-use-effect": {
- "count": 6
- },
- "react/set-state-in-effect": {
- "count": 6
- }
- },
"web/app/components/datasets/documents/detail/metadata/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 1
@@ -3325,7 +3274,7 @@
},
"web/app/components/develop/code.tsx": {
"ts/no-explicit-any": {
- "count": 7
+ "count": 6
}
},
"web/app/components/develop/doc.tsx": {
@@ -3337,9 +3286,6 @@
"jsx-a11y/no-redundant-roles": {
"count": 1
},
- "ts/no-empty-object-type": {
- "count": 1
- },
"ts/no-explicit-any": {
"count": 2
}
@@ -5121,10 +5067,10 @@
},
"web/app/components/workflow/nodes/_base/components/layout/index.tsx": {
"no-barrel-files/no-barrel-files": {
- "count": 7
+ "count": 6
},
"react-refresh/only-export-components": {
- "count": 7
+ "count": 6
}
},
"web/app/components/workflow/nodes/_base/components/mcp-tool-availability.tsx": {
@@ -5486,9 +5432,6 @@
"erasable-syntax-only/enums": {
"count": 1
},
- "no-barrel-files/no-barrel-files": {
- "count": 1
- },
"ts/no-explicit-any": {
"count": 1
}
@@ -7177,11 +7120,6 @@
"count": 1
}
},
- "web/service/__tests__/use-snippet-workflows.spec.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/service/__tests__/use-tools.spec.tsx": {
"no-restricted-imports": {
"count": 1
@@ -7286,7 +7224,7 @@
"count": 1
},
"ts/no-explicit-any": {
- "count": 26
+ "count": 9
}
},
"web/service/datasets.ts": {
@@ -7294,7 +7232,7 @@
"count": 1
},
"ts/no-explicit-any": {
- "count": 6
+ "count": 5
}
},
"web/service/debug.ts": {
diff --git a/packages/dify-ui/src/switch/__tests__/index.spec.tsx b/packages/dify-ui/src/switch/__tests__/index.spec.tsx
index 28aa8a655ce..0e289539581 100644
--- a/packages/dify-ui/src/switch/__tests__/index.spec.tsx
+++ b/packages/dify-ui/src/switch/__tests__/index.spec.tsx
@@ -123,9 +123,13 @@ describe('Switch', () => {
await expect.element(screen.getByRole('switch')).toHaveAttribute('data-checked', '')
})
- it('should have focus-visible ring-3 styles', async () => {
+ it('should replace the native focus outline with the accent focus ring', async () => {
const screen = await render()
- await expect.element(screen.getByRole('switch')).toHaveClass('focus-visible:ring-2')
+ await expect.element(screen.getByRole('switch')).toHaveClass(
+ 'outline-hidden',
+ 'focus-visible:ring-2',
+ 'focus-visible:ring-state-accent-solid',
+ )
})
it('should respect prefers-reduced-motion', async () => {
diff --git a/packages/dify-ui/src/switch/index.tsx b/packages/dify-ui/src/switch/index.tsx
index d35809ecad8..768e009488e 100644
--- a/packages/dify-ui/src/switch/index.tsx
+++ b/packages/dify-ui/src/switch/index.tsx
@@ -10,7 +10,7 @@ import { cn } from '../cn'
const switchRootStateClassName = 'bg-components-toggle-bg-unchecked hover:bg-components-toggle-bg-unchecked-hover data-checked:bg-components-toggle-bg data-checked:hover:bg-components-toggle-bg-hover data-disabled:cursor-not-allowed data-disabled:bg-components-toggle-bg-unchecked-disabled data-disabled:hover:bg-components-toggle-bg-unchecked-disabled data-disabled:data-checked:bg-components-toggle-bg-disabled data-disabled:data-checked:hover:bg-components-toggle-bg-disabled'
const switchRootVariants = cva(
- `group relative inline-flex shrink-0 cursor-pointer touch-manipulation items-center transition-colors duration-200 ease-in-out focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none ${switchRootStateClassName}`,
+ `group relative inline-flex shrink-0 cursor-pointer touch-manipulation items-center outline-hidden transition-colors duration-200 ease-in-out focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none ${switchRootStateClassName}`,
{
variants: {
size: {
diff --git a/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts b/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts
deleted file mode 100644
index cc97065d8f8..00000000000
--- a/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts
+++ /dev/null
@@ -1,188 +0,0 @@
-/**
- * Integration test: DSL export/import flow
- *
- * Validates DSL export logic (sync draft → check secrets → download)
- * and DSL import modal state management.
- */
-import { act, renderHook } from '@testing-library/react'
-import { describe, expect, it, vi } from 'vitest'
-
-const mockDoSyncWorkflowDraft = vi.fn().mockResolvedValue(undefined)
-const mockExportPipelineConfig = vi.fn().mockResolvedValue({ data: 'yaml-content' })
-const mockNotify = vi.fn()
-const mockToast = {
- success: (message: string, options?: Record) => mockNotify({ type: 'success', message, ...options }),
- error: (message: string, options?: Record) => mockNotify({ type: 'error', message, ...options }),
- warning: (message: string, options?: Record) => mockNotify({ type: 'warning', message, ...options }),
- info: (message: string, options?: Record) => mockNotify({ type: 'info', message, ...options }),
- dismiss: vi.fn(),
- update: vi.fn(),
- promise: vi.fn(),
-}
-
-vi.mock('@langgenius/dify-ui/toast', () => ({
- toast: mockToast,
-}))
-const mockEventEmitter = { emit: vi.fn() }
-const mockDownloadBlob = vi.fn()
-
-vi.mock('react-i18next', () => ({
- useTranslation: () => ({
- t: (key: string) => key,
- }),
-}))
-
-vi.mock('@/app/components/workflow/constants', () => ({
- DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
-}))
-
-vi.mock('@/app/components/workflow/store', () => ({
- useWorkflowStore: () => ({
- getState: () => ({
- pipelineId: 'pipeline-abc',
- knowledgeName: 'My Pipeline',
- }),
- }),
-}))
-
-vi.mock('@/context/event-emitter', () => ({
- useEventEmitterContextContext: () => ({
- eventEmitter: mockEventEmitter,
- }),
-}))
-
-vi.mock('@/service/use-pipeline', () => ({
- useExportPipelineDSL: () => ({
- mutateAsync: mockExportPipelineConfig,
- }),
-}))
-
-vi.mock('@/service/workflow', () => ({
- fetchWorkflowDraft: vi.fn(),
-}))
-
-vi.mock('@/utils/download', () => ({
- downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
-}))
-
-vi.mock('@/app/components/rag-pipeline/hooks/use-nodes-sync-draft', () => ({
- useNodesSyncDraft: () => ({
- doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
- }),
-}))
-
-describe('DSL Export/Import Flow', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- describe('Export Flow', () => {
- it('should sync draft then export then download', async () => {
- const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
- const { result } = renderHook(() => useDSL())
-
- await act(async () => {
- await result.current.handleExportDSL()
- })
-
- expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
- expect(mockExportPipelineConfig).toHaveBeenCalledWith({
- pipelineId: 'pipeline-abc',
- include: false,
- })
- expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
- fileName: 'My Pipeline.pipeline',
- }))
- })
-
- it('should export with include flag when specified', async () => {
- const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
- const { result } = renderHook(() => useDSL())
-
- await act(async () => {
- await result.current.handleExportDSL(true)
- })
-
- expect(mockExportPipelineConfig).toHaveBeenCalledWith({
- pipelineId: 'pipeline-abc',
- include: true,
- })
- })
-
- it('should notify on export error', async () => {
- mockDoSyncWorkflowDraft.mockRejectedValueOnce(new Error('sync failed'))
- const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
- const { result } = renderHook(() => useDSL())
-
- await act(async () => {
- await result.current.handleExportDSL()
- })
-
- expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
- type: 'error',
- }))
- })
- })
-
- describe('Export Check Flow', () => {
- it('should export directly when no secret environment variables', async () => {
- const { fetchWorkflowDraft } = await import('@/service/workflow')
- vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({
- environment_variables: [
- { value_type: 'string', key: 'API_URL', value: 'https://api.example.com' },
- ],
- } as unknown as Awaited>)
-
- const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
- const { result } = renderHook(() => useDSL())
-
- await act(async () => {
- await result.current.exportCheck()
- })
-
- // Should proceed to export directly (no secret vars)
- expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
- })
-
- it('should emit DSL_EXPORT_CHECK event when secret variables exist', async () => {
- const { fetchWorkflowDraft } = await import('@/service/workflow')
- vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({
- environment_variables: [
- { value_type: 'secret', key: 'API_KEY', value: '***' },
- ],
- } as unknown as Awaited>)
-
- const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
- const { result } = renderHook(() => useDSL())
-
- await act(async () => {
- await result.current.exportCheck()
- })
-
- expect(mockEventEmitter.emit).toHaveBeenCalledWith(expect.objectContaining({
- type: 'DSL_EXPORT_CHECK',
- payload: expect.objectContaining({
- data: expect.arrayContaining([
- expect.objectContaining({ value_type: 'secret' }),
- ]),
- }),
- }))
- })
-
- it('should notify on export check error', async () => {
- const { fetchWorkflowDraft } = await import('@/service/workflow')
- vi.mocked(fetchWorkflowDraft).mockRejectedValueOnce(new Error('fetch failed'))
-
- const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
- const { result } = renderHook(() => useDSL())
-
- await act(async () => {
- await result.current.exportCheck()
- })
-
- expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
- type: 'error',
- }))
- })
- })
-})
diff --git a/web/__tests__/xss-prevention.test.tsx b/web/__tests__/xss-prevention.test.tsx
deleted file mode 100644
index 233cbebf0ee..00000000000
--- a/web/__tests__/xss-prevention.test.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-/**
- * XSS Prevention Test Suite
- *
- * This test verifies that the XSS vulnerability in block-input has been properly
- * fixed by replacing dangerouslySetInnerHTML with safe React rendering.
- */
-
-import { cleanup, render } from '@testing-library/react'
-import * as React from 'react'
-import BlockInput from '../app/components/base/block-input'
-
-// Mock styles
-vi.mock('../app/components/app/configuration/base/var-highlight/style.module.css', () => ({
- default: {
- item: 'mock-item-class',
- },
-}))
-
-describe('XSS Prevention - Block Input Security', () => {
- afterEach(() => {
- cleanup()
- })
-
- describe('BlockInput Component Security', () => {
- it('should safely render malicious variable names without executing scripts', () => {
- const testInput = 'user@test.com{{}}'
- const { container } = render()
-
- const scriptElements = container.querySelectorAll('script')
- expect(scriptElements).toHaveLength(0)
-
- const textContent = container.textContent
- expect(textContent).toContain(''}
- const { container } = render()
-
- const spanElement = container.querySelector('span')
- const scriptElements = container.querySelectorAll('script')
-
- expect(spanElement?.textContent).toBe('')
- expect(scriptElements).toHaveLength(0)
- })
- })
-})
-
-export {}
diff --git a/web/app/components/app-sidebar/app-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/index.spec.tsx
deleted file mode 100644
index 573cdf02334..00000000000
--- a/web/app/components/app-sidebar/app-info/__tests__/index.spec.tsx
+++ /dev/null
@@ -1,168 +0,0 @@
-import type { App, AppSSO } from '@/types/app'
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import * as React from 'react'
-import { AppModeEnum } from '@/types/app'
-import AppInfo from '..'
-
-const mockDetailPanel = vi.hoisted(() => vi.fn())
-const mockModals = vi.hoisted(() => vi.fn())
-
-let mockAppPermissionKeys = ['app.acl.view_layout']
-const mockSetPanelOpen = vi.fn()
-
-vi.mock('@/context/app-context', () => ({
- useAppContext: () => ({
- workspacePermissionKeys: ['app.create_and_management'],
- }),
- useSelector: (selector: (state: { userProfile: { id: string }, workspacePermissionKeys: string[] }) => unknown) => selector({
- userProfile: { id: 'user-1' },
- workspacePermissionKeys: ['app.create_and_management'],
- }),
-}))
-
-vi.mock('../app-info-trigger', () => ({
- default: React.memo(({ appDetail, expand, onClick }: {
- appDetail: App & Partial
- expand: boolean
- onClick: () => void
- }) => (
-
- )),
-}))
-
-vi.mock('../app-info-detail-panel', () => ({
- default: React.memo((props: { show: boolean, onClose: () => void }) => {
- mockDetailPanel(props)
- return props.show ? : null
- }),
-}))
-
-vi.mock('../app-info-modals', () => ({
- default: React.memo((props: { activeModal: string | null }) => {
- mockModals(props)
- return props.activeModal ? : null
- }),
-}))
-
-const mockAppDetail: App & Partial = {
- id: 'app-1',
- name: 'Test App',
- mode: AppModeEnum.CHAT,
- icon: '🤖',
- icon_type: 'emoji',
- icon_background: '#FFEAD5',
- icon_url: '',
- description: '',
- use_icon_as_answer_icon: false,
- permission_keys: mockAppPermissionKeys,
-} as App & Partial
-
-const mockUseAppInfoActions = {
- appDetail: mockAppDetail,
- panelOpen: false,
- setPanelOpen: mockSetPanelOpen,
- closePanel: vi.fn(),
- activeModal: null as string | null,
- openModal: vi.fn(),
- closeModal: vi.fn(),
- secretEnvList: [],
- setSecretEnvList: vi.fn(),
- onEdit: vi.fn(),
- onCopy: vi.fn(),
- onExport: vi.fn(),
- exportCheck: vi.fn(),
- handleConfirmExport: vi.fn(),
- onConfirmDelete: vi.fn(),
-}
-
-vi.mock('../use-app-info-actions', () => ({
- useAppInfoActions: () => mockUseAppInfoActions,
-}))
-
-describe('AppInfo', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- mockAppPermissionKeys = ['app.acl.view_layout']
- mockUseAppInfoActions.appDetail = mockAppDetail
- mockUseAppInfoActions.appDetail.permission_keys = mockAppPermissionKeys
- mockUseAppInfoActions.panelOpen = false
- mockUseAppInfoActions.activeModal = null
- })
-
- it('should return null when appDetail is not available', () => {
- mockUseAppInfoActions.appDetail = undefined as unknown as App & Partial
- const { container } = render()
- expect(container.innerHTML).toBe('')
- })
-
- it('should render trigger when not onlyShowDetail', () => {
- render()
- expect(screen.getByTestId('trigger'))!.toBeInTheDocument()
- })
-
- it('should not mount detail layer while the app info panel is closed', () => {
- render()
- expect(mockDetailPanel).not.toHaveBeenCalled()
- expect(mockModals).not.toHaveBeenCalled()
- })
-
- it('should not render trigger when onlyShowDetail is true', () => {
- render()
- expect(screen.queryByTestId('trigger')).not.toBeInTheDocument()
- })
-
- it('should pass expand prop to trigger', () => {
- render()
- expect(screen.getByTestId('trigger'))!.toHaveAttribute('data-expand', 'true')
-
- const { unmount } = render()
- const triggers = screen.getAllByTestId('trigger')
- expect(triggers[triggers.length - 1])!.toHaveAttribute('data-expand', 'false')
- unmount()
- })
-
- it('should toggle panel when trigger is clicked and user is editor', async () => {
- const user = userEvent.setup()
- render()
-
- await user.click(screen.getByTestId('trigger'))
-
- expect(mockSetPanelOpen).toHaveBeenCalled()
- const updater = mockSetPanelOpen.mock.calls[0]![0] as (v: boolean) => boolean
- expect(updater(false)).toBe(true)
- expect(updater(true)).toBe(false)
- })
-
- it('should not toggle panel when app ACL does not allow layout access', async () => {
- const user = userEvent.setup()
- mockAppPermissionKeys = []
- mockUseAppInfoActions.appDetail.permission_keys = mockAppPermissionKeys
- render()
-
- await user.click(screen.getByTestId('trigger'))
-
- expect(mockSetPanelOpen).not.toHaveBeenCalled()
- })
-
- it('should show detail panel based on panelOpen when not onlyShowDetail', () => {
- mockUseAppInfoActions.panelOpen = true
- render()
- expect(screen.getByTestId('detail-panel'))!.toBeInTheDocument()
- expect(mockDetailPanel).toHaveBeenCalled()
- })
-
- it('should show detail panel based on openState when onlyShowDetail', () => {
- render()
- expect(screen.getByTestId('detail-panel'))!.toBeInTheDocument()
- })
-
- it('should hide detail panel when openState is false and onlyShowDetail', () => {
- render()
- expect(screen.queryByTestId('detail-panel')).not.toBeInTheDocument()
- expect(mockDetailPanel).not.toHaveBeenCalled()
- expect(mockModals).not.toHaveBeenCalled()
- })
-})
diff --git a/web/app/components/app-sidebar/app-info/index.tsx b/web/app/components/app-sidebar/app-info/index.tsx
index f08c1f12ed8..ea1665d0dbd 100644
--- a/web/app/components/app-sidebar/app-info/index.tsx
+++ b/web/app/components/app-sidebar/app-info/index.tsx
@@ -5,7 +5,6 @@ import { getAppACLCapabilities } from '@/utils/permission'
import AppInfoDetailPanel from './app-info-detail-panel'
import AppInfoModals from './app-info-modals'
import AppInfoTrigger from './app-info-trigger'
-import { useAppInfoActions } from './use-app-info-actions'
type IAppInfoProps = {
expand: boolean
@@ -122,16 +121,3 @@ export const AppInfoView = ({
)
}
-
-const AppInfo = ({ onDetailExpand, ...props }: IAppInfoProps) => {
- const actions = useAppInfoActions({ onDetailExpand })
-
- return (
-
- )
-}
-
-export default React.memo(AppInfo)
diff --git a/web/app/components/app/configuration/base/var-highlight/__tests__/index.spec.tsx b/web/app/components/app/configuration/base/var-highlight/__tests__/index.spec.tsx
index 1add8601c40..e60576004b3 100644
--- a/web/app/components/app/configuration/base/var-highlight/__tests__/index.spec.tsx
+++ b/web/app/components/app/configuration/base/var-highlight/__tests__/index.spec.tsx
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
-import VarHighlight, { varHighlightHTML } from '../index'
+import VarHighlight from '../index'
describe('VarHighlight', () => {
beforeEach(() => {
@@ -35,32 +35,4 @@ describe('VarHighlight', () => {
expect(container.firstChild).toHaveClass('mt-2')
})
})
-
- // Escaping HTML via helper
- describe('varHighlightHTML', () => {
- it('should escape dangerous characters before returning HTML string', () => {
- // Arrange
- const props = { name: '' }
-
- // Act
- const html = varHighlightHTML(props)
-
- // Assert
- expect(html).toContain('<script>alert('xss')</script>')
- expect(html).not.toContain('')).not.toContain('