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('