From f89d647429b71287e987abbd28318a30ea036ce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Wed, 21 Oct 2026 10:48:33 +0800 Subject: [PATCH] feat(api): add end-user tool credential configuration (#44406) --- api/core/workflow/node_runtime.py | 6 + api/services/app_dsl_service.py | 1 + api/services/workflow/graph_check.py | 7 + api/services/workflow/node_defaults.py | 4 +- api/services/workflow/tool_credentials.py | 201 +++++++++ api/services/workflow_service.py | 9 + .../nodes/tool/test_tool_node_runtime.py | 40 +- .../services/test_app_dsl_service.py | 46 ++ .../services/test_workflow_service.py | 135 ++++++ .../workflow/test_tool_credentials.py | 395 ++++++++++++++++++ 10 files changed, 842 insertions(+), 2 deletions(-) create mode 100644 api/services/workflow/tool_credentials.py create mode 100644 api/tests/unit_tests/services/workflow/test_tool_credentials.py diff --git a/api/core/workflow/node_runtime.py b/api/core/workflow/node_runtime.py index c54cd7123d5..97a197c9c84 100644 --- a/api/core/workflow/node_runtime.py +++ b/api/core/workflow/node_runtime.py @@ -550,6 +550,12 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): variable_pool, node_execution_id: str | None = None, ) -> ToolRuntimeHandle: + # Dify owns credential selection; reject unsupported sources before resolving workspace credentials. + source = node_data.get("credential_source", "workspace") + if source != "workspace": + raise ToolRuntimeResolutionError( + f"Tool node {node_id!r}: credential source {source!r} is not supported by this runtime yet." + ) try: tool_runtime = ToolManager.get_workflow_tool_runtime( self._run_context.tenant_id, diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index b8be7825b79..236db9e31c8 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -1062,6 +1062,7 @@ class AppDslService: # filter credential id from tool node if not include_secret and data_type == BuiltinNodeTypes.TOOL: node_data.pop("credential_id", None) + node_data.pop("test_credential_id", None) # filter credential id from agent node if not include_secret and data_type == BuiltinNodeTypes.AGENT: for tool in node_data.get("agent_parameters", {}).get("tools", {}).get("value", []): diff --git a/api/services/workflow/graph_check.py b/api/services/workflow/graph_check.py index 18d6e1569ad..3493f4884df 100644 --- a/api/services/workflow/graph_check.py +++ b/api/services/workflow/graph_check.py @@ -21,6 +21,7 @@ from graphon.nodes.base.node import Node from graphon.variables import VariableBase from models import AppMode from services.workflow.node_defaults import complete_sub_models, editor_defaults, node_data_type +from services.workflow.tool_credentials import check_tool_credentials from services.workflow_service import WorkflowService from services.workflow_variable_reference_validator import SOURCE_HANDLE, branch_handles @@ -321,6 +322,12 @@ def check_graph(graph: Mapping[str, Any], *, mode: AppMode, resources: ResourceC mode=mode, ) issues = [issue for check in _CHECKS for issue in check(view)] + existing_locations = {(issue.node_id, issue.loc) for issue in issues} + issues.extend( + GraphIssue(IssueCode.NODE_DATA_INVALID, issue.message, issue.node_id, _data_loc(issue.node_id, *issue.path)) + for issue in check_tool_credentials(graph, complete=True) + if (issue.node_id, _data_loc(issue.node_id, *issue.path)) not in existing_locations + ) if resources is not None: for node_id, raw in raw_nodes.items(): problem = resources(raw) diff --git a/api/services/workflow/node_defaults.py b/api/services/workflow/node_defaults.py index bf50571f41f..a5a7948ff76 100644 --- a/api/services/workflow/node_defaults.py +++ b/api/services/workflow/node_defaults.py @@ -15,6 +15,7 @@ from core.workflow.node_factory import resolve_workflow_node_class from core.workflow.nodes.human_input.entities import HumanInputNodeData from graphon.enums import BuiltinNodeTypes from graphon.nodes.base.node import Node +from services.workflow.tool_credentials import ToolNodeSpec # Only empty containers: values the editor reads without a guard and that mean "none yet". EDITOR_EMPTY: Final[Mapping[str, Mapping[str, object]]] = { @@ -37,9 +38,10 @@ class HumanInputNodeSpec(HumanInputNodeData): delivery_methods: list[DeliveryChannelConfig] = Field(default_factory=list) -# Graphon keeps these node data opaque; Dify parses them with its own model when the node runs. +# Dify's editor schemas supplement Graphon's execution schemas with application-owned fields. _DIFY_NODE_DATA: Final[Mapping[str, type[BaseModel]]] = { BuiltinNodeTypes.HUMAN_INPUT: HumanInputNodeSpec, + BuiltinNodeTypes.TOOL: ToolNodeSpec, } diff --git a/api/services/workflow/tool_credentials.py b/api/services/workflow/tool_credentials.py new file mode 100644 index 00000000000..19685fa54c9 --- /dev/null +++ b/api/services/workflow/tool_credentials.py @@ -0,0 +1,201 @@ +"""Dify-owned tool credential configuration, independent of Graphon's execution schema.""" + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator + +from graphon.enums import BuiltinNodeTypes +from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType +from models.provider_ids import ToolProviderID + + +class EndUserAuthConfig(BaseModel): + """Missing values represent an unfinished draft, not an executable configuration.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + allowed_credential_types: list[Literal["api-key", "oauth2"]] | None = Field(default=None, max_length=2) + purpose: str | None = Field(default=None, max_length=50) + + @field_validator("allowed_credential_types") + @classmethod + def validate_unique_credential_types( + cls, value: list[Literal["api-key", "oauth2"]] | None + ) -> list[Literal["api-key", "oauth2"]] | None: + if value is not None and len(value) != len(set(value)): + raise ValueError("Authentication methods must not contain duplicates.") + return value + + +class ToolCredentialConfig(BaseModel): + """Static per-node requirements; active Connections belong to the user's Session selections. + + Independent nodes can select different Connections for the same provider. Only an explicit + reuse reference shares another node's authentication; personal Connection IDs stay out of the graph. + """ + + model_config = ConfigDict(frozen=True) + + # Reuse delegates the effective source and settings to the referenced node. + credential_source: Literal["workspace", "end_user", "reuse_node"] = "workspace" + credential_id: str | None = None + end_user_auth: EndUserAuthConfig | None = None + reuse_node_id: str | None = Field(default=None, min_length=1) + test_credential_id: str | None = Field(default=None, min_length=1) + + +class ToolNodeSpec(ToolNodeData, ToolCredentialConfig): + """The editor-facing schema includes Dify policy without changing Graphon's node model.""" + + +@dataclass(frozen=True) +class ToolCredentialIssue: + node_id: str + path: tuple[str | int, ...] + message: str + + +def _same_tool(data: Mapping[str, Any], target_data: Mapping[str, Any]) -> bool: + if any(data.get(field) != target_data.get(field) for field in ("provider_type", "tool_name")): + return False + provider = data.get("provider_id") + target_provider = target_data.get("provider_id") + if not isinstance(provider, str) or not provider or not isinstance(target_provider, str) or not target_provider: + return False + if provider == target_provider: + return True + + if data.get("provider_type") == ToolProviderType.BUILT_IN: + from core.tools.tool_manager import ToolManager + + # Builtin lookup gives local providers precedence over plugin aliases of the same name. + for reference in (provider, target_provider): + try: + ToolManager.get_hardcoded_provider(reference) + except KeyError: + continue + return False + + try: + return str(ToolProviderID(provider)) == str(ToolProviderID(target_provider)) + except ValueError: + return False + + +def _configuration_issues( + node_id: str, data: Mapping[str, Any], config: ToolCredentialConfig +) -> list[ToolCredentialIssue]: + issues: list[ToolCredentialIssue] = [] + if config.credential_source != "end_user" and config.end_user_auth is not None: + issues.append( + ToolCredentialIssue(node_id, ("end_user_auth",), "Only EndUser nodes can declare authentication settings.") + ) + if config.credential_source != "reuse_node" and config.reuse_node_id is not None: + issues.append(ToolCredentialIssue(node_id, ("reuse_node_id",), "Only reuse nodes can reference another node.")) + if config.credential_source == "workspace": + return issues + + if config.credential_id is not None: + issues.append( + ToolCredentialIssue(node_id, ("credential_id",), "Only workspace nodes can bind a production credential.") + ) + if data.get("provider_type") not in (ToolProviderType.BUILT_IN, ToolProviderType.PLUGIN): + issues.append( + ToolCredentialIssue( + node_id, + ("provider_type",), + "EndUser authentication and reuse require a builtin or plugin tool provider.", + ) + ) + for field in ("provider_id", "tool_name"): + value = data.get(field) + if not isinstance(value, str) or not value.strip(): + issues.append(ToolCredentialIssue(node_id, (field,), "Field is required.")) + + if config.credential_source == "reuse_node": + if config.reuse_node_id is None or not config.reuse_node_id.strip(): + issues.append(ToolCredentialIssue(node_id, ("reuse_node_id",), "A reuse target is required.")) + if config.test_credential_id is not None: + issues.append( + ToolCredentialIssue( + node_id, ("test_credential_id",), "Reuse nodes inherit the target's test credential." + ) + ) + return issues + + auth = config.end_user_auth + if auth is None: + issues.append(ToolCredentialIssue(node_id, ("end_user_auth",), "EndUser authentication is required.")) + return issues + if not auth.allowed_credential_types: + issues.append( + ToolCredentialIssue( + node_id, + ("end_user_auth", "allowed_credential_types"), + "At least one authentication method is required.", + ) + ) + if auth.purpose is None or not auth.purpose.strip(): + issues.append(ToolCredentialIssue(node_id, ("end_user_auth", "purpose"), "Field is required.")) + return issues + + +def check_tool_credentials(graph: Mapping[str, Any], *, complete: bool) -> list[ToolCredentialIssue]: + """Check supplied draft fields, optionally including completeness and graph-wide references. + + No credentials are loaded or validated remotely. Test credential availability is checked + when a test uses it, so a deleted test reference cannot prevent production publication. + """ + issues: list[ToolCredentialIssue] = [] + nodes = { + node["id"]: node["data"] + for node in graph.get("nodes", []) + if isinstance(node, Mapping) + and isinstance(node.get("id"), str) + and isinstance(node.get("data"), Mapping) + and node.get("type") != "custom-note" + } + configs: dict[str, ToolCredentialConfig] = {} + for node_id, data in nodes.items(): + if data.get("type") != BuiltinNodeTypes.TOOL: + continue + try: + configs[node_id] = ToolCredentialConfig.model_validate(data) + except ValidationError as error: + issues.extend( + ToolCredentialIssue(node_id, problem["loc"], problem["msg"]) + for problem in error.errors(include_url=False, include_input=False, include_context=False) + ) + + if not complete: + return issues + + for node_id, config in configs.items(): + issues.extend(_configuration_issues(node_id, nodes[node_id], config)) + invalid_nodes = {issue.node_id for issue in issues} + + for node_id, config in configs.items(): + if node_id in invalid_nodes or config.credential_source != "reuse_node": + continue + data = nodes[node_id] + target_id = config.reuse_node_id + target = configs.get(target_id) if target_id is not None else None + target_data = nodes.get(target_id) if target_id is not None else None + if target_id == node_id: + message = "A node cannot reuse its own authentication." + elif target_data is None: + message = f"Reuse target {target_id!r} does not exist." + elif target is None: + message = f"Reuse target {target_id!r} must be a tool node with valid authentication settings." + elif target.credential_source == "reuse_node": + message = f"Reuse target {target_id!r} must configure authentication directly." + elif target_id in invalid_nodes: + message = f"Reuse target {target_id!r} has incomplete authentication settings." + elif not _same_tool(data, target_data): + message = f"Reuse target {target_id!r} must use the same plugin, provider, and tool." + else: + continue + issues.append(ToolCredentialIssue(node_id, ("reuse_node_id",), message)) + return issues diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index b8f3823d052..feb90e8854f 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -96,6 +96,7 @@ from services.errors.app import ( ) from services.human_input_service import HumanInputService from services.system_feature_service import SystemFeatureService +from services.workflow.tool_credentials import ToolCredentialConfig, check_tool_credentials from services.workflow.workflow_converter import WorkflowConverter from services.workflow_ref_service import WorkflowRef from services.workflow_version_number_service import allocate_version_number @@ -801,6 +802,9 @@ class WorkflowService: try: # Extract and validate credentials based on node type if node_type == "tool": + config = ToolCredentialConfig.model_validate(node_data) + if config.credential_source != "workspace": + return credential_id = node_data.get("credential_id") provider = node_data.get("provider_id") if provider: @@ -1834,6 +1838,11 @@ class WorkflowService: if node_type == BuiltinNodeTypes.HUMAN_INPUT: self._validate_human_input_node_data(node_data) + if issues := check_tool_credentials(graph, complete=False): + issue = issues[0] + location = ".".join(str(part) for part in issue.path) + raise ValueError(f"Node {issue.node_id} ({location}): {issue.message}") + def validate_features_structure(self, app_model: App, features: dict[str, Any]): match app_model.mode: case AppMode.ADVANCED_CHAT: diff --git a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py index 4b41e28ec8e..2b9ffe03f15 100644 --- a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py @@ -21,7 +21,7 @@ from core.workflow.node_runtime import DifyToolNodeRuntime from core.workflow.system_variables import build_system_variables from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType -from graphon.nodes.tool.exc import ToolRuntimeInvocationError +from graphon.nodes.tool.exc import ToolRuntimeInvocationError, ToolRuntimeResolutionError from graphon.nodes.tool_runtime_entities import ToolRuntimeHandle, ToolRuntimeMessage from graphon.runtime import VariablePool from tests.workflow_test_utils import build_test_graph_init_params, build_test_variable_pool @@ -66,6 +66,44 @@ def _build_tool_node_data() -> ToolNodeData: ) +@pytest.mark.parametrize("source", ["end_user", "reuse_node", "invalid", None]) +def test_unimplemented_or_invalid_sources_cannot_fall_back_to_workspace_credentials(source: object) -> None: + params = build_test_graph_init_params() + runtime = DifyToolNodeRuntime(params.run_context) + data = ToolNodeData.model_validate( + { + **_build_tool_node_data().model_dump(), + "credential_id": "old-workspace-credential", + "credential_source": source, + } + ) + + with patch.object(ToolManager, "get_workflow_tool_runtime") as get_runtime: + with pytest.raises(ToolRuntimeResolutionError, match="personal.*credential source"): + runtime.get_runtime(node_id="personal", node_data=data, variable_pool=None) + + get_runtime.assert_not_called() + + +def test_node_factory_keeps_dify_credential_source_from_the_execution_graph() -> None: + from core.workflow.node_factory import DifyNodeFactory + from graphon.runtime import GraphRuntimeState + + data = _build_tool_node_data().model_dump(mode="json") + data["credential_source"] = "end_user" + params = build_test_graph_init_params(graph_config={"nodes": [{"id": "personal", "data": data}]}) + factory = DifyNodeFactory(params, GraphRuntimeState(variable_pool=build_test_variable_pool(), start_at=0)) + node = factory.create_node({"id": "personal", "data": data}) + data["credential_source"] = "workspace" + assert isinstance(node.node_data, ToolNodeData) + + with patch.object(ToolManager, "get_workflow_tool_runtime") as get_runtime: + with pytest.raises(ToolRuntimeResolutionError, match="end_user"): + factory._tool_runtime.get_runtime(node_id="personal", node_data=node.node_data, variable_pool=None) + + get_runtime.assert_not_called() + + def test_invoke_creates_callback_and_converts_messages(runtime: DifyToolNodeRuntime) -> None: core_message = ToolInvokeMessage( type=ToolInvokeMessage.MessageType.LINK, diff --git a/api/tests/unit_tests/services/test_app_dsl_service.py b/api/tests/unit_tests/services/test_app_dsl_service.py index 287ef0709d1..6f5e3dec510 100644 --- a/api/tests/unit_tests/services/test_app_dsl_service.py +++ b/api/tests/unit_tests/services/test_app_dsl_service.py @@ -954,6 +954,52 @@ def test_append_workflow_export_data_reports_missing_selected_workflow( ) +@pytest.mark.parametrize("include_secret", [False, True]) +def test_workflow_export_filters_test_credentials_but_keeps_personal_requirements( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, include_secret: bool +) -> None: + node_data = { + "type": "tool", + "provider_type": "builtin", + "provider_id": "langgenius/mail/mail", + "credential_source": "end_user", + "credential_id": None, + "test_credential_id": "workspace-test-credential", + "end_user_auth": {"allowed_credential_types": ["oauth2", "api-key"], "purpose": "Read mail."}, + } + reuse_data = {"type": "tool", "credential_source": "reuse_node", "reuse_node_id": "personal"} + workflow = _workflow( + graph={ + "nodes": [ + {"id": "personal", "data": node_data}, + {"id": "reuse", "data": reuse_data}, + ], + "edges": [], + } + ) + monkeypatch.setattr(AppDslService, "_load_export_workflow", Mock(return_value=workflow)) + monkeypatch.setattr(AppDslService, "_extract_dependencies_from_workflow", Mock(return_value=[])) + monkeypatch.setattr( + "services.app_dsl_service.AgentDslService.export_workflow_packages", + lambda _self, **kwargs: (kwargs["graph"], {}), + ) + exported: dict = {} + + AppDslService._append_workflow_export_data( + export_data=exported, + app_model=_app(mode=AppMode.WORKFLOW), + include_secret=include_secret, + session=sqlite_session, + ) + + exported_data = exported["workflow"]["graph"]["nodes"][0]["data"] + assert ("test_credential_id" in exported_data) is include_secret + assert exported_data["end_user_auth"] == node_data["end_user_auth"] + assert exported_data["credential_source"] == "end_user" + assert exported["workflow"]["graph"]["nodes"][1]["data"] == reuse_data + assert workflow.graph_dict["nodes"][0]["data"] == node_data + + @pytest.mark.parametrize( "mode", [AppMode.AGENT, AppMode.WORKFLOW, AppMode.ADVANCED_CHAT, AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.COMPLETION], diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index 89b27a2c634..a01c583f671 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -736,6 +736,85 @@ class TestWorkflowService: assert workflow.environment_variables == environment_variables assert workflow.conversation_variables == conversation_variables + @pytest.mark.parametrize("collaborative", [False, True]) + @pytest.mark.parametrize( + "credential_settings", + [ + {"credential_source": "end_user", "end_user_auth": {}}, + { + "credential_source": "end_user", + "end_user_auth": {"allowed_credential_types": ["oauth2", "api-key"], "purpose": "Read mail."}, + "test_credential_id": "workspace-test-credential", + }, + {"credential_source": "reuse_node"}, + {"credential_source": "reuse_node", "reuse_node_id": "workspace"}, + ], + ) + def test_sync_draft_preserves_tool_credentials_and_unfinished_configuration( + self, + workflow_service: WorkflowService, + sqlite_session: Session, + collaborative: bool, + credential_settings: dict[str, object], + ) -> None: + app = TestWorkflowAssociatedDataFactory.create_app() + account = TestWorkflowAssociatedDataFactory.create_account() + graph = { + "nodes": [ + { + "id": "personal", + "data": { + "type": "tool", + "provider_type": "builtin", + "provider_id": "langgenius/mail/mail", + **credential_settings, + }, + }, + {"id": "workspace", "data": {"type": "tool", "credential_id": "shared"}}, + ], + "edges": [], + } + with patch("services.workflow_service.app_draft_workflow_was_synced"): + saved = workflow_service.sync_draft_workflow( + app_model=app, + graph=graph, + features={}, + unique_hash=None, + account=account, + environment_variables=[], + conversation_variables=[], + session=sqlite_session, + graph_only=collaborative, + ) + + sqlite_session.expire(saved) + loaded = workflow_service.get_draft_workflow(app, session=sqlite_session) + assert loaded is not None + assert loaded.graph_dict == graph + + def test_malformed_tool_credentials_do_not_replace_the_saved_graph( + self, workflow_service: WorkflowService, sqlite_session: Session + ) -> None: + workflow = TestWorkflowAssociatedDataFactory.create_workflow() + sqlite_session.add(workflow) + sqlite_session.commit() + original = workflow.graph_dict + invalid = {"nodes": [{"id": "personal", "data": {"type": "tool", "credential_source": "invalid"}}]} + + with pytest.raises(ValueError, match="personal.*credential_source"): + workflow_service.sync_draft_workflow( + app_model=TestWorkflowAssociatedDataFactory.create_app(), + graph=invalid, + features={}, + unique_hash=workflow.unique_hash, + account=TestWorkflowAssociatedDataFactory.create_account(), + environment_variables=[], + conversation_variables=[], + session=sqlite_session, + ) + + assert workflow.graph_dict == original + def test_sync_draft_workflow_raises_hash_not_equal_error( self, workflow_service: WorkflowService, sqlite_session: Session ): @@ -2155,6 +2234,62 @@ class TestWorkflowServiceCredentialValidation: # --- _validate_workflow_credentials: tool node (with credential_id) --- + @pytest.mark.parametrize("credential_id", [None, "old-workspace-credential"]) + @pytest.mark.parametrize("source", ["end_user", "reuse_node"]) + def test_indirect_credentials_do_not_check_workspace_or_test_credentials( + self, service: WorkflowService, sqlite_session: Session, credential_id: str | None, source: str + ) -> None: + workflow = self._make_workflow( + [ + { + "id": "personal", + "data": { + "type": "tool", + "provider_id": "langgenius/mail/mail", + "credential_source": source, + "credential_id": credential_id, + "test_credential_id": "deleted-test-credential", + }, + } + ] + ) + + with ( + patch.object(service, "_check_default_tool_credential") as check_default, + patch("core.helper.credential_utils.check_credential_policy_compliance") as check_policy, + ): + service._validate_workflow_credentials(workflow, session=sqlite_session) + + check_default.assert_not_called() + check_policy.assert_not_called() + + def test_reused_workspace_credential_is_checked_on_the_target_node( + self, service: WorkflowService, sqlite_session: Session + ) -> None: + workflow = self._make_workflow( + [ + {"id": "target", "data": {"type": "tool", "provider_id": "provider", "credential_id": "shared"}}, + { + "id": "reuse", + "data": { + "type": "tool", + "provider_id": "provider", + "credential_source": "reuse_node", + "reuse_node_id": "target", + }, + }, + ] + ) + with ( + patch.object(service, "_check_default_tool_credential") as check_default, + patch("core.helper.credential_utils.check_credential_policy_compliance") as check_policy, + ): + service._validate_workflow_credentials(workflow, session=sqlite_session) + + check_default.assert_not_called() + check_policy.assert_called_once() + assert check_policy.call_args.kwargs["credential_id"] == "shared" + def test_validate_workflow_credentials_should_check_tool_credential_when_credential_id_present( self, service: WorkflowService, sqlite_session: Session ) -> None: diff --git a/api/tests/unit_tests/services/workflow/test_tool_credentials.py b/api/tests/unit_tests/services/workflow/test_tool_credentials.py new file mode 100644 index 00000000000..12e4d97ec5e --- /dev/null +++ b/api/tests/unit_tests/services/workflow/test_tool_credentials.py @@ -0,0 +1,395 @@ +from copy import deepcopy +from typing import TypedDict, cast + +import pytest + +from graphon.nodes.tool.entities import ToolNodeData +from models import AppMode +from services.workflow.graph_check import IssueCode, check_graph +from services.workflow.node_defaults import node_data_type +from services.workflow.tool_credentials import ToolCredentialConfig, ToolNodeSpec, check_tool_credentials + + +class ToolNodeFixture(TypedDict): + id: str + data: dict[str, object] + + +def tool_node(node_id: str = "tool", /, **overrides: object) -> ToolNodeFixture: + return { + "id": node_id, + "data": { + "type": "tool", + "title": node_id, + "provider_id": "langgenius/calendar/calendar", + "provider_type": "builtin", + "provider_name": "calendar", + "tool_name": "read", + "tool_label": "Read", + "tool_configurations": {}, + "tool_parameters": {}, + "credential_source": "end_user", + "end_user_auth": { + "allowed_credential_types": ["oauth2"], + "purpose": "Read the user's calendar.", + }, + **overrides, + }, + } + + +def test_legacy_nodes_keep_their_workspace_source_without_rewriting_data() -> None: + node = tool_node(credential_id="workspace-key") + del node["data"]["credential_source"] + del node["data"]["end_user_auth"] + original = deepcopy(node) + + assert ToolCredentialConfig.model_validate(node["data"]).credential_source == "workspace" + assert check_tool_credentials({"nodes": [node]}, complete=True) == [] + assert node == original + + +@pytest.mark.parametrize("auth", [None, {}, {"allowed_credential_types": []}, {"purpose": " "}]) +def test_unfinished_drafts_can_be_saved_but_are_not_ready(auth: object) -> None: + graph = {"nodes": [tool_node(end_user_auth=auth)]} + + assert check_tool_credentials(graph, complete=False) == [] + assert check_tool_credentials(graph, complete=True) + + +@pytest.mark.parametrize( + ("overrides", "path"), + [ + ({"credential_source": "personal"}, ("credential_source",)), + ({"credential_source": None}, ("credential_source",)), + ({"test_credential_id": 1}, ("test_credential_id",)), + ({"test_credential_id": ""}, ("test_credential_id",)), + ( + {"end_user_auth": {"allowed_credential_types": ["unauthorized"]}}, + ("end_user_auth", "allowed_credential_types", 0), + ), + ({"end_user_auth": {"allowed_credential_types": "oauth2"}}, ("end_user_auth", "allowed_credential_types")), + ( + {"end_user_auth": {"allowed_credential_types": ["oauth2", "oauth2"]}}, + ("end_user_auth", "allowed_credential_types"), + ), + ({"reuse_node_id": ""}, ("reuse_node_id",)), + ({"end_user_auth": {"purpose": "x" * 51}}, ("end_user_auth", "purpose")), + ({"end_user_auth": {"connection_id": "personal-connection"}}, ("end_user_auth", "connection_id")), + ], +) +def test_malformed_draft_fields_are_located(overrides: dict[str, object], path: tuple[str | int, ...]) -> None: + graph = {"nodes": [tool_node(**overrides)]} + [issue] = check_tool_credentials(graph, complete=False) + + assert (issue.node_id, issue.path) == ("tool", path) + + +def test_purpose_limit_counts_characters_not_utf8_bytes() -> None: + node = tool_node() + auth = cast(dict[str, object], node["data"]["end_user_auth"]) + auth["purpose"] = "\u4e2d" * 50 + graph = {"nodes": [node]} + + assert check_tool_credentials(graph, complete=True) == [] + + auth["purpose"] = "\u4e2d" * 51 + [issue] = check_tool_credentials(graph, complete=False) + + assert issue.path == ("end_user_auth", "purpose") + + +def test_mixed_sources_and_missing_test_credentials_are_valid() -> None: + nodes = [ + tool_node("workspace", credential_source="workspace", end_user_auth=None, credential_id="shared"), + tool_node("personal", test_credential_id="deleted-test-credential"), + tool_node("independent"), + ] + graph = {"nodes": nodes} + + assert check_tool_credentials(graph, complete=True) == [] + assert check_graph(graph, mode=AppMode.WORKFLOW) == [] + + +@pytest.mark.parametrize( + ("overrides", "path"), + [ + ({"credential_id": "shared"}, ("credential_id",)), + ({"credential_source": "workspace"}, ("end_user_auth",)), + ({"provider_type": "mcp"}, ("provider_type",)), + ({"provider_type": "api"}, ("provider_type",)), + ({"provider_id": " "}, ("provider_id",)), + ], +) +def test_complete_configuration_rejects_invalid_sources_and_providers( + overrides: dict[str, object], path: tuple[str, ...] +) -> None: + [issue] = check_tool_credentials({"nodes": [tool_node(**overrides)]}, complete=True) + + assert issue.path == path + + +@pytest.mark.parametrize("methods", [["oauth2"], ["api-key"], ["oauth2", "api-key"]]) +def test_end_user_nodes_allow_one_or_both_authentication_methods(methods: list[str]) -> None: + node = tool_node() + cast(dict[str, object], node["data"]["end_user_auth"])["allowed_credential_types"] = methods + + assert check_tool_credentials({"nodes": [node]}, complete=True) == [] + + +@pytest.mark.parametrize( + ("first_methods", "second_methods"), + [ + (["oauth2"], ["api-key"]), + (["oauth2", "api-key"], ["api-key"]), + (["oauth2", "api-key"], ["api-key", "oauth2"]), + ], +) +def test_same_provider_nodes_have_independent_authentication_requirements( + first_methods: list[str], second_methods: list[str] +) -> None: + first = tool_node("first") + second = tool_node("second") + cast(dict[str, object], first["data"]["end_user_auth"])["allowed_credential_types"] = first_methods + cast(dict[str, object], second["data"]["end_user_auth"])["allowed_credential_types"] = second_methods + + graph = {"nodes": [first, second]} + original = deepcopy(graph) + + assert check_tool_credentials(graph, complete=True) == [] + assert check_graph(graph, mode=AppMode.WORKFLOW) == [] + assert graph == original + + +def test_provider_grouping_does_not_merge_node_requirements_or_test_credentials() -> None: + nodes = [tool_node("both"), tool_node("oauth"), tool_node("api-key")] + for node, methods in zip(nodes, (["oauth2", "api-key"], ["oauth2"], ["api-key"]), strict=True): + cast(dict[str, object], node["data"]["end_user_auth"])["allowed_credential_types"] = methods + node["data"]["test_credential_id"] = f"test-{node['id']}" + + graph = {"nodes": nodes} + original = deepcopy(graph) + + assert check_graph(graph, mode=AppMode.WORKFLOW) == [] + assert graph == original + + +def reuse_node(node_id: str, target: str | None) -> ToolNodeFixture: + node = tool_node(node_id, credential_source="reuse_node", reuse_node_id=target) + del node["data"]["end_user_auth"] + return node + + +def test_explicit_reuse_does_not_constrain_other_nodes_of_the_same_provider() -> None: + oauth = tool_node("oauth") + api_key = tool_node("api-key") + cast(dict[str, object], api_key["data"]["end_user_auth"])["allowed_credential_types"] = ["api-key"] + graph = {"nodes": [oauth, api_key, reuse_node("reuse", "oauth")]} + + assert check_graph(graph, mode=AppMode.WORKFLOW) == [] + + +@pytest.mark.parametrize("source", ["workspace", "end_user"]) +@pytest.mark.parametrize("target_first", [False, True]) +def test_reuse_inherits_direct_workspace_or_end_user_auth_without_duplicate_settings( + source: str, target_first: bool +) -> None: + target = tool_node("target") + if source == "workspace": + target["data"].update(credential_source="workspace", end_user_auth=None, credential_id="shared") + else: + target["data"]["test_credential_id"] = "test-credential" + reuse = reuse_node("reuse", "target") + graph = {"nodes": [target, reuse] if target_first else [reuse, target]} + + assert check_tool_credentials(graph, complete=True) == [] + assert check_graph(graph, mode=AppMode.WORKFLOW) == [] + assert "end_user_auth" not in reuse["data"] + assert "test_credential_id" not in reuse["data"] + + +def test_reuse_of_legacy_workspace_auth_keeps_default_credential_resolution() -> None: + target = tool_node("target") + del target["data"]["credential_source"] + del target["data"]["end_user_auth"] + + assert check_tool_credentials({"nodes": [target, reuse_node("reuse", "target")]}, complete=True) == [] + + +@pytest.mark.parametrize( + ("legacy_id", "canonical_id"), + [ + ("google", "langgenius/google/google"), + ("jina", "langgenius/jina_tool/jina"), + ("siliconflow", "langgenius/siliconflow_tool/siliconflow"), + ("stepfun", "langgenius/stepfun_tool/stepfun"), + ("gitee_ai", "langgenius/gitee_ai_tool/gitee_ai"), + ], +) +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize("source", ["workspace", "end_user"]) +def test_reuse_accepts_plugin_aliases_without_rewriting_stored_nodes( + legacy_id: str, canonical_id: str, reverse: bool, source: str +) -> None: + target = tool_node("target", provider_id=canonical_id if reverse else legacy_id) + if source == "workspace": + target["data"].update(credential_source="workspace", end_user_auth=None, credential_id="shared") + reuse = reuse_node("reuse", "target") + reuse["data"]["provider_id"] = legacy_id if reverse else canonical_id + graph = {"nodes": [target, reuse]} + original = deepcopy(graph) + + assert check_graph(graph, mode=AppMode.WORKFLOW) == [] + assert graph == original + + +@pytest.mark.parametrize("provider_type", ["builtin", "plugin"]) +@pytest.mark.parametrize("reverse", [False, True]) +def test_local_provider_names_are_not_plugin_aliases_in_builtin_mode(provider_type: str, reverse: bool) -> None: + target = tool_node("target", provider_type=provider_type, provider_id="langgenius/time/time" if reverse else "time") + reuse = reuse_node("reuse", "target") + reuse["data"].update(provider_type=provider_type, provider_id="time" if reverse else "langgenius/time/time") + + issues = check_tool_credentials({"nodes": [target, reuse]}, complete=True) + + if provider_type == "plugin": + assert issues == [] + else: + [issue] = issues + assert (issue.node_id, issue.path) == ("reuse", ("reuse_node_id",)) + + +def test_matching_local_providers_remain_compatible() -> None: + target = tool_node("target", provider_id="time", credential_source="workspace", end_user_auth=None) + reuse = reuse_node("reuse", "target") + reuse["data"]["provider_id"] = "time" + + assert check_tool_credentials({"nodes": [target, reuse]}, complete=True) == [] + + +@pytest.mark.parametrize( + "overrides", + [ + {"provider_id": "other/jina_tool/jina"}, + {"provider_id": "langgenius/jina_tool/other"}, + {"provider_id": "invalid/provider"}, + {"provider_id": "langgenius/jina_tool/jina", "tool_name": "other-tool"}, + {"provider_id": "langgenius/jina_tool/jina", "provider_type": "plugin"}, + ], +) +def test_alias_compatibility_preserves_provider_and_tool_boundaries(overrides: dict[str, object]) -> None: + target = tool_node("target", provider_id="jina") + reuse = reuse_node("reuse", "target") + reuse["data"].update(overrides) + + [issue] = check_tool_credentials({"nodes": [target, reuse]}, complete=True) + + assert (issue.node_id, issue.path) == ("reuse", ("reuse_node_id",)) + + +def test_alias_comparison_does_not_load_remote_providers(monkeypatch: pytest.MonkeyPatch) -> None: + from unittest.mock import Mock + + from core.tools.tool_manager import ToolManager + + remote_lookup = Mock(side_effect=AssertionError("Static checks must not fetch plugin metadata")) + monkeypatch.setattr(ToolManager, "get_plugin_provider", remote_lookup) + target = tool_node("target", provider_id="jina") + reuse = reuse_node("reuse", "target") + reuse["data"]["provider_id"] = "langgenius/jina_tool/jina" + + assert check_tool_credentials({"nodes": [target, reuse]}, complete=True) == [] + remote_lookup.assert_not_called() + + +@pytest.mark.parametrize( + "overrides", + [ + {"credential_id": "own-credential"}, + {"test_credential_id": "own-test-credential"}, + {"end_user_auth": {"allowed_credential_types": ["api-key"], "purpose": "Override target."}}, + ], +) +def test_reuse_cannot_override_the_target_authentication(overrides: dict[str, object]) -> None: + reuse = reuse_node("reuse", "target") + reuse["data"].update(overrides) + + [issue] = check_tool_credentials({"nodes": [tool_node("target"), reuse]}, complete=True) + + assert issue.node_id == "reuse" + assert issue.path == (next(iter(overrides)),) + + +@pytest.mark.parametrize( + "target", [None, "missing", "start", "incomplete", "other-provider", "other-plugin", "other-tool"] +) +def test_invalid_reuse_targets_are_located(target: str | None) -> None: + nodes = [ + reuse_node("reuse", target), + {"id": "start", "data": {"type": "start"}}, + tool_node("incomplete", end_user_auth={}), + tool_node("other-provider", provider_id="langgenius/calendar/other"), + tool_node("other-plugin", provider_id="vendor/calendar/calendar"), + tool_node("other-tool", tool_name="send"), + ] + issues = check_tool_credentials({"nodes": nodes}, complete=True) + [issue] = [issue for issue in issues if issue.node_id == "reuse"] + + assert issue.path == ("reuse_node_id",) + + +@pytest.mark.parametrize("source", ["workspace", "end_user"]) +def test_direct_authentication_cannot_reference_a_reuse_target(source: str) -> None: + node = tool_node(credential_source=source, reuse_node_id="other") + if source == "workspace": + node["data"]["end_user_auth"] = None + + [issue] = check_tool_credentials({"nodes": [node]}, complete=True) + + assert issue.path == ("reuse_node_id",) + + +def test_reuse_chains_are_rejected_even_without_a_cycle() -> None: + graph = {"nodes": [reuse_node("child", "middle"), reuse_node("middle", "root"), tool_node("root")]} + + [issue] = check_tool_credentials(graph, complete=True) + + assert issue.node_id == "child" + assert issue.path == ("reuse_node_id",) + assert "directly" in issue.message + + +@pytest.mark.parametrize("cycle", [["self"], ["a", "b"], ["a", "b", "c"]]) +def test_self_reuse_and_reuse_cycles_are_rejected(cycle: list[str]) -> None: + nodes = [reuse_node(node_id, cycle[(index + 1) % len(cycle)]) for index, node_id in enumerate(cycle)] + issues = check_tool_credentials({"nodes": nodes}, complete=True) + + assert [issue.node_id for issue in issues] == cycle + assert all(issue.path == ("reuse_node_id",) for issue in issues) + + +def test_unfinished_reuse_drafts_can_be_saved() -> None: + graph = {"nodes": [reuse_node("reuse", None)]} + + assert check_tool_credentials(graph, complete=False) == [] + [issue] = check_tool_credentials(graph, complete=True) + assert issue.path == ("reuse_node_id",) + + +def test_graph_check_reports_config_errors_once_at_the_field_location() -> None: + issues = check_graph({"nodes": [tool_node(credential_source="invalid")]}, mode=AppMode.WORKFLOW) + + assert [(issue.code, issue.loc) for issue in issues] == [ + (IssueCode.NODE_DATA_INVALID, ("nodes", "tool", "data", "credential_source")) + ] + + +def test_dify_schema_exposes_credentials_without_extending_graphon() -> None: + from graphon.nodes.tool.tool_node import ToolNode + + assert node_data_type("tool", ToolNode) is ToolNodeSpec + assert "credential_source" in ToolNodeSpec.model_json_schema()["properties"] + assert "credential_source" not in ToolNodeData.model_fields + data = tool_node()["data"] + assert ToolNodeSpec.model_validate(data).end_user_auth is not None + assert ToolNodeData.model_validate(data).get("credential_source") == "end_user"