Files
dify/api/tests/unit_tests/services/test_draft_tool_admission.py
T

1780 lines
76 KiB
Python

"""Fresh draft admission at native owners, independently of Enterprise RBAC.
A dispatch sentinel replaces only the final generator/engine boundary. Real
SQLite draft/credential rows and plugin declarations feed every denial. Removing
the setup description cannot supply permission; setting up the same row can.
"""
import json
from collections.abc import Callable, Iterator, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Never, Self
import httpx
import pytest
from pydantic import JsonValue
from sqlalchemy import Engine, Select, select, text
from sqlalchemy.engine import Connection
from sqlalchemy.orm import ORMExecuteState, Session, SessionTransaction, sessionmaker
import services.app_generate_service as generate_module
import services.workflow_service as workflow_module
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity
from core.dify_builder.models import Graph
from core.plugin.impl.oauth import OAuthHandler
from core.tools.__base.tool import Tool
from core.tools.errors import ToolProviderCredentialValidationError
from core.tools.tool_manager import ToolManager
from graphon.nodes.tool.tool_node import ToolNode
from graphon.runtime import VariablePool
from models.account import Account
from models.credential_permission import CredentialPermission, CredentialType
from models.enums import EndUserType, PermissionEnum
from models.enums import InvokeFrom as ModelInvokeFrom
from models.model import App, AppMode, EndUser
from models.workflow import Workflow, WorkflowRun, WorkflowType
from services.app_generate_service import AppGenerateService
from services.workflow_service import WorkflowService
from tests.unit_tests.services.dify_builder.draft_tool_inventory import (
APP_ID,
AUTH_PROVIDER,
DRAFT_ID,
FREE_PROVIDER,
DraftToolInventory,
)
from tests.unit_tests.services.dify_builder.draft_tool_inventory import (
draft_tools as installed_draft_tools,
)
from tests.unit_tests.services.dify_builder.installed_tool_inventory import (
ACCOUNT_A,
ACCOUNT_B,
OTHER_TENANT_ID,
SECRET_MARKER,
TENANT_ID,
add_credential,
record_sql,
)
draft_tools = installed_draft_tools
# Preserve the production method before the metadata fixture forbids runtime use.
_NATIVE_TOOL_RUNTIME = ToolManager.get_tool_runtime.__func__
_NATIVE_OAUTH_REFRESH = OAuthHandler.refresh_credentials
class NativeDispatchReachedError(AssertionError):
"""Baseline behavioral RED: native dispatch occurred before setup admission."""
def dispatch(*args: object, **kwargs: object) -> Never: # noqa: ARG001
raise NativeDispatchReachedError("native generator/engine reached before draft Tool setup admission")
class LocalRateLimit:
"""External Redis concurrency boundary only; executes the real action."""
def __init__(self, *args: object) -> None:
pass
@staticmethod
def gen_request_key() -> str:
return "draft-setup-test"
def enter(self, request_id: str) -> str:
return request_id
def exit(self, request_id: str) -> None:
pass
def generate[T](self, generator: T, request_id: str) -> T: # noqa: ARG002
return generator
@dataclass
class NativeDraftCase:
app: App
user: Account
workflow: Workflow
tools: DraftToolInventory
@dataclass
class DraftDatabase:
engine: Engine
session: Session | None = None
@dataclass
class RefreshBoundary:
action: Callable[[], None] | None = None
credential_owner: str = ACCOUNT_A
@dataclass
class NativeOAuthCase:
resolve: Callable[[], Tool]
case: NativeDraftCase
engine: Engine
credential_id: str
crypto_calls: list[str]
refresh_calls: list[str]
cache_deletions: list[str]
forbidden: list[str]
refresh_boundary: RefreshBoundary
@dataclass
class ResumeEvidence:
run_id: str
task_id: str
saved_blob: bytes = b""
redis_calls: list[tuple[str, ...]] = field(default_factory=list)
native_calls: list[str] = field(default_factory=list)
pause_writes: list[object] = field(default_factory=list)
storage_loads: list[str] = field(default_factory=list)
run: WorkflowRun = field(init=False)
entity: WorkflowAppGenerateEntity | AdvancedChatAppGenerateEntity = field(init=False)
mode: AppMode = field(init=False)
executed_graph: Graph = field(init=False)
store: Callable[[AppMode, Graph | str | None], None] = field(init=False)
save_context: Callable[[], None] = field(init=False)
resume: Callable[[], None] = field(init=False)
assert_not_resumed: Callable[[], None] = field(init=False)
redis_values: dict[str, bytes | list[bytes]] = field(init=False)
@dataclass
class FailureResponseTopic:
messages: list[bytes] = field(default_factory=list)
def publish(self, value: bytes) -> None:
self.messages.append(value)
@pytest.fixture
def native_draft(
draft_tools: DraftToolInventory,
sqlite_session: Session,
monkeypatch: pytest.MonkeyPatch,
config_overrides: Callable[..., None],
) -> NativeDraftCase:
import core.tools.tool_manager as runtime_module
import services.tools.ordinary_tool_credentials as credential_owner
# Ordinary credential SQL/crypto moved out of ToolManager. Bind the new
# owner to the same real SQLite database and existing fail-closed sentinel.
# Its legacy label is retained so every no-secret assertion stays unchanged.
monkeypatch.setattr(credential_owner, "db", runtime_module.db)
monkeypatch.setattr(credential_owner, "create_provider_encrypter", runtime_module.create_provider_encrypter)
config_overrides(DEPLOYMENT_EDITION="COMMUNITY")
monkeypatch.setattr(generate_module, "RateLimit", LocalRateLimit)
monkeypatch.setattr(workflow_module.SystemFeatureService, "is_plugin_manager_enabled", lambda: False)
# Sending publication signals may enqueue external tasks. Preserve actual
# graph validation, version allocation, ORM creation, and agent ownership.
monkeypatch.setattr(workflow_module.app_published_workflow_was_updated, "send", lambda *_args, **_kwargs: None)
app = App(
id=APP_ID,
tenant_id=TENANT_ID,
name="Draft tools",
description="",
mode=AppMode.WORKFLOW,
enable_api=True,
enable_site=True,
max_active_requests=0,
)
user = Account(name="Acting member", email="actor@example.test")
user.id = ACCOUNT_A
graph: Graph = {
"nodes": [
{"id": "s", "type": "custom", "data": {"type": "start", "title": "Start", "variables": []}},
{
"id": "t",
"type": "custom",
"data": {
"type": "tool",
"title": "Post to Huddle",
"provider_id": AUTH_PROVIDER,
"provider_name": AUTH_PROVIDER,
"provider_type": "builtin",
"tool_name": "post",
"tool_label": "Post to Huddle",
"tool_parameters": {"message": {"type": "mixed", "value": "hello"}},
"tool_configurations": {},
"desc": "Builder reminder: configure credentials before testing or publishing.",
},
},
{"id": "e", "type": "custom", "data": {"type": "end", "title": "End", "outputs": []}},
],
"edges": [{"id": "e1", "source": "s", "target": "t"}, {"id": "e2", "source": "t", "target": "e"}],
}
workflow = Workflow(
id=DRAFT_ID,
tenant_id=TENANT_ID,
app_id=APP_ID,
type=WorkflowType.WORKFLOW,
version=Workflow.VERSION_DRAFT,
graph=json.dumps(graph),
features="{}",
created_by=ACCOUNT_B,
environment_variables=[],
conversation_variables=[],
rag_pipeline_variables=[],
)
sqlite_session.add_all([app, workflow])
sqlite_session.commit()
return NativeDraftCase(app=app, user=user, workflow=workflow, tools=draft_tools)
def set_tool(case: NativeDraftCase, session: Session, **values: object) -> None:
graph = case.workflow.graph_dict
assert isinstance(graph, dict)
graph["nodes"][1]["data"].update(values)
case.workflow.graph = json.dumps(graph)
session.commit()
ENTRYPOINTS = [
"workflow-full",
"chat-full",
"workflow-iteration",
"chat-iteration",
"workflow-loop",
"chat-loop",
"node",
]
def nest_tool_in_container(case: NativeDraftCase, session: Session, kind: str) -> None:
"""Use a real container id and enclosing Tool rather than a nonexistent node."""
graph = case.workflow.graph_dict
assert isinstance(graph, dict)
tool = next(node for node in graph["nodes"] if node["id"] == "t")
tool["parentId"] = "container"
tool["data"].update({f"isIn{kind.title()}": True, f"{kind}_id": "container"})
container_data: dict[str, object] = {
"type": kind,
"title": kind.title(),
"start_node_id": "containerstart",
"error_handle_mode": "terminated",
"is_parallel": False,
}
if kind == "iteration":
graph["nodes"][0]["data"]["variables"] = [
{"variable": "items", "label": "Items", "type": "json_object", "required": True}
]
container_data.update({"iterator_selector": ["s", "items"], "output_selector": ["t", "text"]})
else:
container_data.update({"loop_count": 1, "break_conditions": [], "logical_operator": "and"})
graph["nodes"] = [
graph["nodes"][0],
{"id": "container", "type": "custom", "data": container_data},
{
"id": "containerstart",
"type": f"custom-{kind}-start",
"parentId": "container",
"data": {"type": f"{kind}-start", "title": "Start"},
},
tool,
next(node for node in graph["nodes"] if node["id"] == "e"),
]
graph["edges"] = [
{"id": "outer-in", "source": "s", "target": "container"},
{"id": "inner", "source": "containerstart", "target": "t"},
{"id": "outer-out", "source": "container", "target": "e"},
]
case.workflow.graph = json.dumps(graph)
session.commit()
def invoke(
case: NativeDraftCase,
session: Session,
monkeypatch: pytest.MonkeyPatch,
entrypoint: str,
*,
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
) -> object:
"""Call the real request owner through its SQL workflow lookup."""
if entrypoint == "publish":
return WorkflowService(sessionmaker(bind=case.tools.engine)).publish_workflow(
session=session,
app_model=case.app,
account=case.user,
)
case.app.mode = AppMode.ADVANCED_CHAT if entrypoint.startswith("chat-") else AppMode.WORKFLOW
session.commit()
for generator in (generate_module.WorkflowAppGenerator, generate_module.AdvancedChatAppGenerator):
for method in ("generate", "single_iteration_generate", "single_loop_generate"):
monkeypatch.setattr(generator, method, dispatch)
monkeypatch.setattr(workflow_module.WorkflowEntry, "single_step_run", dispatch)
if entrypoint == "node":
return WorkflowService(sessionmaker(bind=case.tools.engine)).run_draft_workflow_node(
case.app,
case.workflow,
"t",
{},
case.user,
)
if entrypoint.endswith("iteration"):
nest_tool_in_container(case, session, "iteration")
return AppGenerateService.generate_single_iteration(
case.app,
case.user,
"container",
{"inputs": {}},
session=session,
streaming=False,
)
if entrypoint.endswith("loop"):
from fields.workflow_run_fields import LoopNodeRunPayload
nest_tool_in_container(case, session, "loop")
return AppGenerateService.generate_single_loop(
case.app,
case.user,
"container",
LoopNodeRunPayload(inputs={}),
session=session,
streaming=False,
)
return AppGenerateService.generate(
case.app,
case.user,
{"inputs": {}, "query": "hello"},
invoke_from,
session=session,
streaming=False,
workflow_execution_mode="in_process",
)
@pytest.mark.parametrize("entrypoint", ENTRYPOINTS)
@pytest.mark.parametrize("description", ["", "User removed or changed the reminder; ready now!"])
def test_draft_execution_without_credentials_refuses_before_native_dispatch_even_without_note(
native_draft: NativeDraftCase,
sqlite_session: Session,
monkeypatch: pytest.MonkeyPatch,
entrypoint: str,
description: str,
) -> None:
set_tool(native_draft, sqlite_session, desc=description)
with pytest.raises(ToolProviderCredentialValidationError, match="Tool|Saved draft execution"):
invoke(native_draft, sqlite_session, monkeypatch, entrypoint)
assert native_draft.tools.forbidden == []
@pytest.mark.parametrize("entrypoint", [*ENTRYPOINTS, "publish"])
@pytest.mark.parametrize("hidden_selection", ["default", "oldest", "legacy-short-default"])
def test_native_default_hidden_credential_is_not_replaced_by_some_visible_credential(
native_draft: NativeDraftCase,
sqlite_session: Session,
monkeypatch: pytest.MonkeyPatch,
entrypoint: str,
hidden_selection: str,
) -> None:
hidden = add_credential(
sqlite_session,
tenant_id=TENANT_ID,
provider="huddle" if hidden_selection == "legacy-short-default" else AUTH_PROVIDER,
user_id=ACCOUNT_B,
visibility=PermissionEnum.ONLY_ME,
name="Hidden native selection",
)
hidden.is_default = hidden_selection != "oldest"
hidden.created_at = datetime(2026, 10, 1)
visible = add_credential(
sqlite_session,
tenant_id=TENANT_ID,
provider=AUTH_PROVIDER,
user_id=ACCOUNT_A,
name="Actor-visible alternative",
)
visible.is_default = False
visible.created_at = datetime(2026, 10, 2)
sqlite_session.commit()
assert native_draft.tools.queries.list_visible_tool_provider_ids(workspace_id=TENANT_ID, actor_id=ACCOUNT_A) == {
AUTH_PROVIDER
}
with pytest.raises(ToolProviderCredentialValidationError, match="Tool|Saved draft execution"):
invoke(native_draft, sqlite_session, monkeypatch, entrypoint)
assert hidden.id != visible.id
assert native_draft.tools.forbidden == []
@pytest.mark.parametrize("selection", ["hidden", "foreign", "wrong-provider", "deleted", "revoked-grant", "malformed"])
@pytest.mark.parametrize("operation", ["workflow-full", "node", "publish"])
def test_exact_selected_credential_requires_the_complete_actor_provider_tenant_chain(
native_draft: NativeDraftCase,
sqlite_session: Session,
monkeypatch: pytest.MonkeyPatch,
selection: str,
operation: str,
) -> None:
credential = add_credential(
sqlite_session,
tenant_id=OTHER_TENANT_ID if selection == "foreign" else TENANT_ID,
provider=FREE_PROVIDER if selection == "wrong-provider" else AUTH_PROVIDER,
user_id=ACCOUNT_B if selection in ("hidden", "revoked-grant") else ACCOUNT_A,
visibility=PermissionEnum.PARTIAL_TEAM if selection == "revoked-grant" else PermissionEnum.ONLY_ME,
)
credential_id = credential.id
if selection == "deleted":
sqlite_session.delete(credential)
sqlite_session.commit()
elif selection == "malformed":
credential_id = "not-a-credential-id"
elif selection == "revoked-grant":
sqlite_session.add(
CredentialPermission(
credential_id=credential_id,
credential_type=CredentialType.BUILTIN_TOOL_PROVIDER,
account_id=ACCOUNT_A,
tenant_id=TENANT_ID,
has_permission=False,
)
)
sqlite_session.commit()
set_tool(native_draft, sqlite_session, credential_id=credential_id, desc="")
with pytest.raises(ToolProviderCredentialValidationError, match="Tool|Saved draft execution") as refused:
invoke(native_draft, sqlite_session, monkeypatch, operation)
assert SECRET_MARKER not in str(refused.value)
assert native_draft.tools.forbidden == []
@pytest.mark.parametrize("entrypoint", ENTRYPOINTS)
@pytest.mark.parametrize("configured", ["explicit", "default", "credential-free"])
def test_configured_and_credential_free_drafts_reach_native_dispatch_without_removing_the_note(
native_draft: NativeDraftCase,
sqlite_session: Session,
monkeypatch: pytest.MonkeyPatch,
entrypoint: str,
configured: str,
) -> None:
if configured == "credential-free":
set_tool(native_draft, sqlite_session, provider_id=FREE_PROVIDER, provider_name=FREE_PROVIDER, tool_name="echo")
else:
credential = add_credential(
sqlite_session,
tenant_id=TENANT_ID,
provider=AUTH_PROVIDER,
user_id=ACCOUNT_A,
name="Explicit actor credential",
)
if configured == "explicit":
# An explicit authorized selection overrides the hidden default.
credential.is_default = False
sqlite_session.commit()
add_credential(
sqlite_session,
tenant_id=TENANT_ID,
provider=AUTH_PROVIDER,
user_id=ACCOUNT_B,
visibility=PermissionEnum.ONLY_ME,
name="Hidden provider default",
)
set_tool(native_draft, sqlite_session, credential_id=credential.id)
statements = record_sql(native_draft.tools.engine)
with pytest.raises(NativeDispatchReachedError):
invoke(native_draft, sqlite_session, monkeypatch, entrypoint)
assert not [s for s in statements if "encrypted_credentials" in s]
assert native_draft.tools.forbidden == []
def test_setup_enables_the_same_stored_draft_without_regeneration(
native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
graph_before = native_draft.workflow.graph
with pytest.raises(ToolProviderCredentialValidationError, match="Tool|Saved draft execution"):
invoke(native_draft, sqlite_session, monkeypatch, "workflow-full")
add_credential(sqlite_session, tenant_id=TENANT_ID, provider=AUTH_PROVIDER, user_id=ACCOUNT_A)
with pytest.raises(NativeDispatchReachedError):
invoke(native_draft, sqlite_session, monkeypatch, "workflow-full")
assert native_draft.workflow.id == DRAFT_ID
assert native_draft.workflow.graph == graph_before
assert native_draft.tools.forbidden == []
@pytest.mark.parametrize(
"failure", ["credential-query", "declaration-query", "uninstalled", "unknown-action", "foreign-installation"]
)
@pytest.mark.parametrize("operation", [*ENTRYPOINTS, "publish"])
def test_fresh_query_or_installation_uncertainty_refuses_draft_admission(
native_draft: NativeDraftCase,
sqlite_session: Session,
monkeypatch: pytest.MonkeyPatch,
failure: str,
operation: str,
) -> None:
add_credential(sqlite_session, tenant_id=TENANT_ID, provider=AUTH_PROVIDER, user_id=ACCOUNT_A)
if failure == "credential-query":
with native_draft.tools.engine.begin() as connection:
connection.execute(text("DROP TABLE tool_builtin_providers"))
elif failure == "declaration-query":
native_draft.tools.daemon.fail_all = True
elif failure == "uninstalled":
native_draft.tools.daemon.installed_ids_by_tenant = {TENANT_ID: ["acme/free"]}
elif failure == "unknown-action":
set_tool(native_draft, sqlite_session, tool_name="not_installed")
elif failure == "foreign-installation":
set_tool(
native_draft, sqlite_session, provider_id="foreign/huddle/huddle", provider_name="foreign/huddle/huddle"
)
with pytest.raises(ToolProviderCredentialValidationError, match="Tool|Saved draft execution"):
invoke(native_draft, sqlite_session, monkeypatch, operation)
assert native_draft.tools.forbidden == []
def test_publish_without_credentials_and_plugin_manager_disabled_creates_no_version_or_pointer(
native_draft: NativeDraftCase,
sqlite_session: Session,
) -> None:
service = WorkflowService(sessionmaker(bind=native_draft.tools.engine))
before_pointer = native_draft.app.workflow_id
with pytest.raises(ToolProviderCredentialValidationError, match="Tool|Saved draft execution"):
service.publish_workflow(session=sqlite_session, app_model=native_draft.app, account=native_draft.user)
sqlite_session.flush()
assert [row.version for row in sqlite_session.scalars(select(Workflow).where(Workflow.app_id == APP_ID))] == [
"draft"
]
# Signal receivers are isolated above: this assertion covers the service's
# in-memory pointer only, not the normal signal-driven database update owner.
assert native_draft.app.workflow_id == before_pointer
assert native_draft.tools.forbidden == []
@pytest.mark.parametrize("configured", ["explicit", "default", "credential-free"])
def test_publish_configured_or_credential_free_draft_with_plugin_manager_disabled(
native_draft: NativeDraftCase,
sqlite_session: Session,
configured: str,
) -> None:
if configured == "credential-free":
set_tool(native_draft, sqlite_session, provider_id=FREE_PROVIDER, provider_name=FREE_PROVIDER, tool_name="echo")
else:
row = add_credential(sqlite_session, tenant_id=TENANT_ID, provider=AUTH_PROVIDER, user_id=ACCOUNT_A)
if configured == "explicit":
set_tool(native_draft, sqlite_session, credential_id=row.id)
statements = record_sql(native_draft.tools.engine)
published = WorkflowService(sessionmaker(bind=native_draft.tools.engine)).publish_workflow(
session=sqlite_session,
app_model=native_draft.app,
account=native_draft.user,
)
sqlite_session.flush()
assert published.version != "draft"
assert published.graph_dict == native_draft.workflow.graph_dict
assert not [s for s in statements if "encrypted_credentials" in s]
assert native_draft.tools.forbidden == []
@pytest.mark.parametrize("invoke_from", [InvokeFrom.SERVICE_API, InvokeFrom.WEB_APP])
def test_published_execution_keeps_normal_credential_delegation(
native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, invoke_from: InvokeFrom
) -> None:
native_draft.workflow.version = "2026-10-12 12:00:00"
native_draft.app.workflow_id = DRAFT_ID
end_user = EndUser(
id="eeeeeeee-0000-4000-8000-000000000001",
tenant_id=TENANT_ID,
app_id=APP_ID,
type=EndUserType.SERVICE_API if invoke_from == InvokeFrom.SERVICE_API else EndUserType.BROWSER,
session_id="ffffffff-0000-4000-8000-000000000001",
external_user_id="external-customer-1",
name="External customer",
_is_anonymous=False,
)
sqlite_session.add(end_user)
sqlite_session.commit()
monkeypatch.setattr(generate_module.WorkflowAppGenerator, "generate", dispatch)
with pytest.raises(NativeDispatchReachedError):
AppGenerateService.generate(
app_model=native_draft.app,
user=end_user,
args={"inputs": {}, "query": "hello"},
invoke_from=invoke_from,
session=sqlite_session,
streaming=False,
workflow_execution_mode="in_process",
)
assert native_draft.tools.forbidden == []
@pytest.mark.parametrize("owner", ["console", "builder"])
def test_native_publish_owners_read_declarations_before_writes_or_draft_locks(
native_draft: NativeDraftCase,
sqlite_session: Session,
monkeypatch: pytest.MonkeyPatch,
owner: str,
) -> None:
"""A real locked/write publication cannot perform external schema I/O."""
from importlib import import_module
from inspect import unwrap
from sqlalchemy import event
from core.dify_builder.models import Actor
from services.dify_builder.revision import executable_graph_revision, execution_revision
# Console route registration binds its ``app`` attribute to app.app;
# resolve the exact leaf rather than following that package alias.
console_owner = import_module("controllers.console.app.workflow")
builder_owner = import_module("services.dify_builder.dify_port")
add_credential(sqlite_session, tenant_id=TENANT_ID, provider=AUTH_PROVIDER, user_id=ACCOUNT_A)
sqlite_session.add(native_draft.user)
sqlite_session.commit()
database = DraftDatabase(engine=native_draft.tools.engine)
monkeypatch.setattr(console_owner, "db", database)
monkeypatch.setattr(builder_owner, "db", database)
guarded_sessions: set[Session] = set()
write_connections: set[Connection] = set()
requests: list[str] = []
daemon_request = native_draft.tools.daemon.request
def on_orm_execute(state: ORMExecuteState) -> None:
if (
state.session.bind is native_draft.tools.engine
and isinstance(state.statement, Select)
and state.statement._for_update_arg is not None
):
guarded_sessions.add(state.session)
def on_flush(session: Session, _context: object, _instances: object) -> None:
if session.bind is native_draft.tools.engine and (session.new or session.dirty or session.deleted):
guarded_sessions.add(session)
def on_end(session: Session, transaction: SessionTransaction) -> None:
if transaction.parent is None:
guarded_sessions.discard(session)
def on_sql(
connection: Connection,
_cursor: object,
statement: str,
_parameters: object,
_context: object,
_executemany: bool,
) -> None:
if statement.lstrip().upper().startswith(("INSERT ", "UPDATE ", "DELETE ")):
write_connections.add(connection)
def on_connection_end(connection: Connection) -> None:
write_connections.discard(connection)
def metadata_request(
*,
method: str,
url: str,
params: dict[str, object] | None = None,
content: str | bytes | None = None,
data: dict[str, object] | None = None,
**kwargs: object,
) -> httpx.Response:
assert not guarded_sessions, "external Tool declarations fetched during writes or a held draft lock"
assert not write_connections, "external Tool declarations fetched inside an active SQL write transaction"
requests.append(url)
return daemon_request(method=method, url=url, params=params, content=content, data=data, **kwargs)
event.listen(Session, "do_orm_execute", on_orm_execute)
event.listen(Session, "before_flush", on_flush)
event.listen(Session, "after_transaction_end", on_end)
event.listen(native_draft.tools.engine, "before_cursor_execute", on_sql)
event.listen(native_draft.tools.engine, "commit", on_connection_end)
event.listen(native_draft.tools.engine, "rollback", on_connection_end)
monkeypatch.setattr(native_draft.tools.daemon, "request", metadata_request)
try:
if owner == "console":
unwrap(console_owner.PublishedWorkflowApi.post)(
console_owner.PublishedWorkflowApi(),
args=console_owner.PublishWorkflowPayload(),
current_user=native_draft.user,
app_model=native_draft.app,
)
else:
builder_owner.WorkflowServiceDifyPort().publish(
APP_ID,
Actor(account_id=ACCOUNT_A, tenant_id=TENANT_ID),
expected_revision=execution_revision(native_draft.workflow),
expected_graph_revision=executable_graph_revision(dict(native_draft.workflow.graph_dict)),
)
assert requests, "native publication never verified the actual installed Tool declaration"
sqlite_session.expire_all()
stored_app = sqlite_session.get(App, APP_ID)
assert stored_app is not None
assert stored_app.workflow_id != DRAFT_ID
assert stored_app.workflow_id is not None
assert len(list(sqlite_session.scalars(select(Workflow).where(Workflow.app_id == APP_ID)))) == 2
assert native_draft.tools.forbidden == []
finally:
event.remove(Session, "do_orm_execute", on_orm_execute)
event.remove(Session, "before_flush", on_flush)
event.remove(Session, "after_transaction_end", on_end)
event.remove(native_draft.tools.engine, "before_cursor_execute", on_sql)
event.remove(native_draft.tools.engine, "commit", on_connection_end)
event.remove(native_draft.tools.engine, "rollback", on_connection_end)
@pytest.mark.parametrize("mode", [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def test_queued_draft_rechecks_revoked_selection_before_native_dispatch(
native_draft: NativeDraftCase,
sqlite_session: Session,
monkeypatch: pytest.MonkeyPatch,
mode: AppMode,
) -> None:
"""Request-time setup is not authorization after a queued credential revoke."""
import tasks.app_generate.workflow_execute_task as worker
credential = add_credential(
sqlite_session,
tenant_id=TENANT_ID,
provider=AUTH_PROVIDER,
user_id=ACCOUNT_B,
visibility=PermissionEnum.PARTIAL_TEAM,
partial_members=[ACCOUNT_A],
)
native_draft.app.mode = mode
sqlite_session.add(native_draft.user)
sqlite_session.commit()
payload = worker.AppExecutionParams.new(
app_model=native_draft.app,
workflow=native_draft.workflow,
user=native_draft.user,
args={"inputs": {}, "query": "hello"},
invoke_from=InvokeFrom.DEBUGGER,
streaming=True,
).model_dump_json()
grant = sqlite_session.scalar(
select(CredentialPermission).where(CredentialPermission.credential_id == credential.id)
)
assert grant is not None
grant.has_permission = False
sqlite_session.commit()
monkeypatch.setattr(worker, "db", DraftDatabase(engine=native_draft.tools.engine))
monkeypatch.setattr(worker.WorkflowAppGenerator, "generate", dispatch)
monkeypatch.setattr(worker.AdvancedChatAppGenerator, "generate", dispatch)
response_topic = FailureResponseTopic()
monkeypatch.setattr(
worker.MessageBasedAppGenerator,
"get_response_topic",
lambda *_args: response_topic,
)
with pytest.raises(ToolProviderCredentialValidationError, match="Tool|Saved draft execution"):
worker.workflow_based_app_execution_task.run(payload)
assert native_draft.tools.forbidden == []
@pytest.mark.parametrize("default", [True, False])
def test_ambiguous_native_default_cannot_mask_hidden_credential(
native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, default: bool
) -> None:
visible = add_credential(
sqlite_session,
tenant_id=TENANT_ID,
provider=AUTH_PROVIDER,
user_id=ACCOUNT_A,
visibility=PermissionEnum.ONLY_ME,
name="Visible tied native selection",
)
hidden = add_credential(
sqlite_session,
tenant_id=TENANT_ID,
provider=AUTH_PROVIDER,
user_id=ACCOUNT_B,
visibility=PermissionEnum.ONLY_ME,
name="Hidden tied native selection",
)
visible.is_default = hidden.is_default = default
visible.created_at = hidden.created_at = datetime(2026, 1, 1)
sqlite_session.commit()
with pytest.raises(ToolProviderCredentialValidationError, match="Tool|Saved draft execution"):
invoke(native_draft, sqlite_session, monkeypatch, "workflow-full")
# An explicit authorized choice removes the native ambiguity on this same node.
set_tool(native_draft, sqlite_session, credential_id=visible.id)
with pytest.raises(NativeDispatchReachedError):
invoke(native_draft, sqlite_session, monkeypatch, "workflow-full")
assert native_draft.tools.forbidden == []
def native_tool_node(
case: NativeDraftCase, *, workflow_id: str | None = None, credential_bindings: dict[str, str] | None = None
) -> tuple[ToolNode, VariablePool]:
"""Construct the real Tool through the production native factory and scope."""
from core.app.entities.app_invoke_entities import UserFrom, build_dify_run_context
from graphon.runtime import RuntimeState, VariablePool
from services.workflow_runtime.node_factory import DifyGraphInitContext, DifyNodeFactory
pool = VariablePool()
factory = DifyNodeFactory.from_graph_init_context(
graph_init_context=DifyGraphInitContext(
workflow_id=workflow_id or case.workflow.id,
graph_config=case.workflow.graph_dict,
run_context=build_dify_run_context(
tenant_id=TENANT_ID,
app_id=APP_ID,
user_id=ACCOUNT_A,
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
builder_tool_credential_bindings=credential_bindings,
),
call_depth=0,
),
runtime_state=RuntimeState(variable_pool=pool, start_at=0.0, workflow_id=workflow_id or case.workflow.id),
)
node = factory.create_node(case.workflow.get_node_config_by_id("t"))
assert isinstance(node, ToolNode)
return node, pool
@pytest.mark.parametrize("change", ["revoked", "hidden-default", "wrong-provider", "after-metadata"])
def test_native_tool_rechecks_selection_after_run_admission(
native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, change: str
) -> None:
"""A native node cannot decrypt a selection whose access changed since admission."""
from sqlalchemy import event
from graphon.nodes.tool.exc import ToolRuntimeResolutionError
from models.tools import BuiltinToolProvider
credential = add_credential(
sqlite_session,
tenant_id=TENANT_ID,
provider=AUTH_PROVIDER,
user_id=ACCOUNT_B,
visibility=PermissionEnum.PARTIAL_TEAM,
partial_members=[ACCOUNT_A],
)
if change != "hidden-default":
set_tool(native_draft, sqlite_session, credential_id=credential.id)
else:
sqlite_session.commit()
# Real public admission succeeds before the eventual Tool executes.
with pytest.raises(NativeDispatchReachedError):
invoke(native_draft, sqlite_session, monkeypatch, "workflow-full")
grant = sqlite_session.scalar(
select(CredentialPermission).where(CredentialPermission.credential_id == credential.id)
)
if change == "revoked":
assert grant is not None
grant.has_permission = False
elif change == "hidden-default":
credential.is_default = False
add_credential(
sqlite_session,
tenant_id=TENANT_ID,
provider=AUTH_PROVIDER,
user_id=ACCOUNT_B,
visibility=PermissionEnum.ONLY_ME,
name="New hidden default",
)
elif change == "wrong-provider":
credential.provider = FREE_PROVIDER
sqlite_session.commit()
monkeypatch.setattr(ToolManager, "get_tool_runtime", classmethod(_NATIVE_TOOL_RUNTIME))
node, pool = native_tool_node(native_draft)
revoked_between_reads: list[bool] = []
def revoke_before_native_value_read(state: ORMExecuteState) -> None:
if change == "after-metadata" and not revoked_between_reads:
descriptions = state.statement.column_descriptions if isinstance(state.statement, Select) else ()
if descriptions and descriptions[0].get("expr") is BuiltinToolProvider:
revoked_between_reads.append(True)
assert grant is not None
grant.has_permission = False
sqlite_session.commit()
event.listen(Session, "do_orm_execute", revoke_before_native_value_read)
try:
with pytest.raises(ToolRuntimeResolutionError, match="authorized Tool credential"):
node._runtime.get_runtime(node_id="t", node_data=node.node_data, variable_pool=pool)
assert revoked_between_reads == ([True] if change == "after-metadata" else [])
assert native_draft.tools.forbidden == []
finally:
event.remove(Session, "do_orm_execute", revoke_before_native_value_read)
def test_published_child_inherited_debugger_keeps_native_tool_delegation(
native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Actual published child scope preserves delegation even with inherited DEBUGGER."""
from graphon.nodes.tool.exc import ToolRuntimeResolutionError
credential = add_credential(
sqlite_session,
tenant_id=TENANT_ID,
provider=AUTH_PROVIDER,
user_id=ACCOUNT_B,
visibility=PermissionEnum.ONLY_ME,
)
set_tool(native_draft, sqlite_session, credential_id=credential.id)
owner = Account(name="Publishing credential owner", email="credential-owner@example.test")
owner.id = ACCOUNT_B
sqlite_session.add(owner)
sqlite_session.commit()
published = WorkflowService(sessionmaker(bind=native_draft.tools.engine)).publish_workflow(
session=sqlite_session,
app_model=native_draft.app,
# The credential owner performs actual publication, preserving native scope.
account=owner,
)
sqlite_session.commit()
# Only actual publication above needs draft setup metadata. Inherited debugger
# execution of the published child must reach the existing native crypto owner.
metadata_reads = len(native_draft.tools.daemon.requests)
monkeypatch.setattr(ToolManager, "get_tool_runtime", classmethod(_NATIVE_TOOL_RUNTIME))
import core.tools.tool_manager as tool_manager_module
monkeypatch.setattr(
tool_manager_module, "db", DraftDatabase(engine=native_draft.tools.engine, session=sqlite_session)
)
node, pool = native_tool_node(native_draft, workflow_id=published.id)
with pytest.raises(
ToolRuntimeResolutionError, match="discovery must not call tool_manager.create_provider_encrypter"
):
node._runtime.get_runtime(node_id="t", node_data=node.node_data, variable_pool=pool)
assert native_draft.tools.forbidden == ["tool_manager.create_provider_encrypter"]
# Native schema fetch is possible, but installed-catalogue admission must not run.
assert not any("/management/tools" in r.path for r in native_draft.tools.daemon.requests[metadata_reads:])
@pytest.mark.parametrize("scope", ["deleted", "foreign"])
def test_native_tool_unknown_workflow_scope_refuses_before_crypto(
native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, scope: str
) -> None:
from graphon.nodes.tool.exc import ToolRuntimeResolutionError
add_credential(sqlite_session, tenant_id=TENANT_ID, provider=AUTH_PROVIDER, user_id=ACCOUNT_A)
if scope == "deleted":
sqlite_session.delete(native_draft.workflow)
else:
native_draft.workflow.tenant_id = OTHER_TENANT_ID
sqlite_session.commit()
monkeypatch.setattr(ToolManager, "get_tool_runtime", classmethod(_NATIVE_TOOL_RUNTIME))
node, pool = native_tool_node(native_draft)
with pytest.raises(ToolRuntimeResolutionError, match="workflow scope could not be verified"):
node._runtime.get_runtime(node_id="t", node_data=node.node_data, variable_pool=pool)
assert native_draft.tools.forbidden == []
@pytest.fixture
def native_draft_oauth(
native_draft: NativeDraftCase,
sqlite_session: Session,
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[NativeOAuthCase]:
"""Authorized synthetic OAuth effects through real native/SQL ownership.
Actual stored draft ownership, installed declaration, credential visibility,
native ToolManager/OAuth orchestration and ORM commit stay real. Synthetic
crypto and daemon/Redis boundaries permit one authorized refresh only.
"""
from threading import Lock
import contexts
import core.tools.tool_manager as runtime_module
import services.tools.builtin_tools_manage_service as client_module
import services.tools.ordinary_tool_credentials as credential_owner
from core.helper import provider_cache
from core.plugin.entities.plugin_daemon import CredentialType as ToolCredentialType
from core.plugin.entities.plugin_daemon import PluginOAuthCredentialsResponse
from core.tools.entities.tool_entities import ToolInvokeFrom, ToolProviderType
from models.tools import ToolOAuthTenantClient
# Earlier direct native calls can populate the implicit counter-zero cache.
# The first recycle increment merely initializes zero; explicitly start a
# fresh native request cache, as the existing native fixtures do.
def reset_native_provider_cache() -> None:
contexts.plugin_tool_providers.set({})
contexts.plugin_tool_providers_lock.set(Lock())
reset_native_provider_cache()
try:
declaration = native_draft.tools.daemon.providers_by_tenant[TENANT_ID][0]["declaration"]
assert isinstance(declaration, dict)
empty_schema: list[JsonValue] = []
declaration["credentials_schema"] = empty_schema
declaration["oauth_schema"] = {
"client_schema": [
{"name": "client_id", "type": "text-input"},
{"name": "client_secret", "type": "secret-input"},
],
"credentials_schema": [
{"name": "access_token", "type": "secret-input"},
{"name": "refresh_token", "type": "secret-input"},
],
}
controller = ToolManager.get_builtin_provider(AUTH_PROVIDER, TENANT_ID)
assert [schema.name for schema in controller.get_credentials_schema_by_type(ToolCredentialType.OAUTH2)] == [
"access_token",
"refresh_token",
]
assert [schema.name for schema in controller.get_oauth_client_schema()] == ["client_id", "client_secret"]
credential = add_credential(
sqlite_session,
tenant_id=TENANT_ID,
provider=AUTH_PROVIDER,
user_id=ACCOUNT_A,
visibility=PermissionEnum.ONLY_ME,
)
credential.credential_type = ToolCredentialType.OAUTH2
credential.encrypted_credentials = json.dumps(
{
"access_token": "cipher:old-access",
"refresh_token": "cipher:old-refresh",
}
)
credential.expires_at = 1
client = ToolOAuthTenantClient(tenant_id=TENANT_ID, plugin_id="acme/huddle", provider="huddle")
client.enabled = True
client.encrypted_oauth_params = json.dumps(
{
"client_id": "synthetic-client",
"client_secret": "cipher:client-secret",
}
)
sqlite_session.add_all([client, native_draft.user])
sqlite_session.commit()
set_tool(native_draft, sqlite_session, credential_id=credential.id)
credential_id = credential.id
native_database = DraftDatabase(engine=native_draft.tools.engine, session=sqlite_session)
monkeypatch.setattr(runtime_module, "db", native_database)
monkeypatch.setattr(credential_owner, "db", native_database)
monkeypatch.setattr(client_module, "db", native_database)
monkeypatch.setattr(ToolManager, "get_tool_runtime", classmethod(_NATIVE_TOOL_RUNTIME))
monkeypatch.setattr(OAuthHandler, "refresh_credentials", _NATIVE_OAUTH_REFRESH)
crypto_calls: list[str] = []
refresh_calls: list[str] = []
cache_deletions: list[str] = []
refresh_boundary = RefreshBoundary()
class SyntheticCrypto:
@staticmethod
def decrypt(values: dict[str, object]) -> dict[str, str]:
crypto_calls.append("decrypt")
if values == {"client_id": "synthetic-client", "client_secret": "cipher:client-secret"}:
return {"client_id": "synthetic-client", "client_secret": "synthetic-client-secret"}
if values == {"access_token": "cipher:new-access", "refresh_token": "cipher:new-refresh"}:
return {"access_token": "synthetic-new-access", "refresh_token": "synthetic-new-refresh"}
assert values == {"access_token": "cipher:old-access", "refresh_token": "cipher:old-refresh"}
return {"access_token": "synthetic-old-access", "refresh_token": "synthetic-old-refresh"}
@staticmethod
def encrypt(values: dict[str, object]) -> dict[str, str]:
crypto_calls.append("encrypt")
assert values == {"access_token": "synthetic-new-access", "refresh_token": "synthetic-new-refresh"}
return {"access_token": "cipher:new-access", "refresh_token": "cipher:new-refresh"}
def crypto_factory(
*, tenant_id: str, config: Sequence[object], cache: object
) -> tuple[SyntheticCrypto, object]:
assert tenant_id == TENANT_ID
assert len(config) == 2
return SyntheticCrypto(), cache
def refresh_stream(
_self: object,
method: str,
path: str,
response_type: type[PluginOAuthCredentialsResponse],
*,
headers: dict[str, str],
data: Graph,
**_kwargs: object,
) -> Iterator[PluginOAuthCredentialsResponse]:
assert method == "POST"
assert path == "plugin/aaaaaaaa-0000-4000-8000-000000000001/dispatch/oauth/refresh_credentials"
assert response_type is PluginOAuthCredentialsResponse
assert headers == {
"X-Plugin-ID": "acme/huddle",
"Content-Type": "application/json",
"X-Plugin-Unique-Identifier": "acme/huddle:0.0.1@" + "0" * 64,
}
assert data["user_id"] == refresh_boundary.credential_owner
assert data["data"]["provider"] == "huddle"
assert data["data"]["credentials"] == {
"access_token": "synthetic-old-access",
"refresh_token": "synthetic-old-refresh",
}
assert data["data"]["system_credentials"] == {
"client_id": "synthetic-client",
"client_secret": "synthetic-client-secret",
}
refresh_calls.append(path)
if refresh_boundary.action is not None:
refresh_boundary.action()
return iter(
[
PluginOAuthCredentialsResponse(
credentials={"access_token": "synthetic-new-access", "refresh_token": "synthetic-new-refresh"},
expires_at=4102444800,
)
]
)
class RedisBoundary:
@staticmethod
def delete(key: str) -> int:
cache_deletions.append(key)
return 1
monkeypatch.setattr(runtime_module, "create_provider_encrypter", crypto_factory)
monkeypatch.setattr(credential_owner, "create_provider_encrypter", crypto_factory)
monkeypatch.setattr(client_module, "create_provider_encrypter", crypto_factory)
monkeypatch.setattr(OAuthHandler, "_request_with_plugin_daemon_response_stream", refresh_stream)
monkeypatch.setattr(provider_cache, "redis_client", RedisBoundary())
def resolve_runtime() -> Tool:
return ToolManager.get_tool_runtime(
provider_type=ToolProviderType.BUILT_IN,
provider_id=AUTH_PROVIDER,
tool_name="post",
tenant_id=TENANT_ID,
user_id=ACCOUNT_A,
invoke_from=InvokeFrom.DEBUGGER,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
credential_id=credential_id,
draft_tool_workflow_id=DRAFT_ID,
draft_tool_app_id=APP_ID,
)
yield NativeOAuthCase(
resolve=resolve_runtime,
case=native_draft,
engine=native_draft.tools.engine,
credential_id=credential_id,
crypto_calls=crypto_calls,
refresh_calls=refresh_calls,
cache_deletions=cache_deletions,
forbidden=native_draft.tools.forbidden,
refresh_boundary=refresh_boundary,
)
finally:
reset_native_provider_cache()
def test_authorized_native_draft_oauth_refresh_persists_rotated_credentials_and_expiry(
native_draft_oauth: NativeOAuthCase,
) -> None:
"""Native rotation survives fresh SQL and a second resolution without refresh."""
from models.tools import BuiltinToolProvider
boundary = native_draft_oauth
runtime = boundary.resolve()
assert runtime.runtime.credentials == {
"access_token": "synthetic-new-access",
"refresh_token": "synthetic-new-refresh",
}
assert len(boundary.refresh_calls) == 1
assert boundary.crypto_calls == ["decrypt", "decrypt", "encrypt"]
assert len(boundary.cache_deletions) == 1
assert boundary.forbidden == []
# A fresh independent read observes actual committed storage, not either
# runtime state or the fixture session's potentially stale identity map.
with Session(boundary.engine) as fresh_session:
encrypted, expiry = fresh_session.execute(
select(BuiltinToolProvider.encrypted_credentials, BuiltinToolProvider.expires_at).where(
BuiltinToolProvider.tenant_id == TENANT_ID,
BuiltinToolProvider.provider == AUTH_PROVIDER,
BuiltinToolProvider.id == boundary.credential_id,
)
).one()
assert json.loads(encrypted) == {"access_token": "cipher:new-access", "refresh_token": "cipher:new-refresh"}
assert expiry == 4102444800
subsequent_runtime = boundary.resolve()
assert subsequent_runtime.runtime.credentials == {
"access_token": "synthetic-new-access",
"refresh_token": "synthetic-new-refresh",
}
assert len(boundary.refresh_calls) == 1
assert boundary.crypto_calls == ["decrypt", "decrypt", "encrypt", "decrypt"]
assert len(boundary.cache_deletions) == 1
assert boundary.forbidden == []
@pytest.mark.parametrize("change", ["revoked", "rotated"])
def test_native_draft_oauth_refresh_refuses_concurrent_selection_change(
native_draft_oauth: NativeOAuthCase, change: str
) -> None:
"""Real current permission/token updates cannot be overwritten after OAuth I/O."""
from core.tools.errors import ToolProviderCredentialValidationError
from models.tools import BuiltinToolProvider
boundary = native_draft_oauth
if change == "revoked":
# Admit a real teammate-owned partial grant, then revoke during refresh.
with Session(boundary.engine) as session, session.begin():
owner = Account(name="OAuth credential owner", email="oauth-owner@example.test")
owner.id = ACCOUNT_B
session.add(owner)
credential = session.scalar(
select(BuiltinToolProvider).where(
BuiltinToolProvider.id == boundary.credential_id,
BuiltinToolProvider.tenant_id == TENANT_ID,
BuiltinToolProvider.provider == AUTH_PROVIDER,
)
)
assert credential is not None
credential.user_id = ACCOUNT_B
credential.visibility = PermissionEnum.PARTIAL_TEAM
session.add(
CredentialPermission(
credential_id=boundary.credential_id,
credential_type=CredentialType.BUILTIN_TOOL_PROVIDER,
account_id=ACCOUNT_A,
tenant_id=TENANT_ID,
has_permission=True,
)
)
boundary.refresh_boundary.credential_owner = ACCOUNT_B
def concurrent_change() -> None:
with Session(boundary.engine) as session, session.begin():
if change == "revoked":
grant = session.scalar(
select(CredentialPermission).where(
CredentialPermission.tenant_id == TENANT_ID,
CredentialPermission.credential_id == boundary.credential_id,
CredentialPermission.credential_type == CredentialType.BUILTIN_TOOL_PROVIDER,
CredentialPermission.account_id == ACCOUNT_A,
)
)
assert grant is not None
grant.has_permission = False
else:
credential = session.scalar(
select(BuiltinToolProvider).where(
BuiltinToolProvider.tenant_id == TENANT_ID,
BuiltinToolProvider.provider == AUTH_PROVIDER,
BuiltinToolProvider.id == boundary.credential_id,
)
)
assert credential is not None
credential.encrypted_credentials = json.dumps(
{
"access_token": "cipher:concurrent-access",
"refresh_token": "cipher:concurrent-refresh",
}
)
credential.expires_at = 4202444800
boundary.refresh_boundary.action = concurrent_change
with pytest.raises(ToolProviderCredentialValidationError, match="changed or access was revoked"):
boundary.resolve()
assert len(boundary.refresh_calls) == 1
assert boundary.crypto_calls == ["decrypt", "decrypt", "encrypt"]
assert boundary.cache_deletions == []
assert boundary.forbidden == []
with Session(boundary.engine) as session:
encrypted, expiry = session.execute(
select(BuiltinToolProvider.encrypted_credentials, BuiltinToolProvider.expires_at).where(
BuiltinToolProvider.tenant_id == TENANT_ID,
BuiltinToolProvider.provider == AUTH_PROVIDER,
BuiltinToolProvider.id == boundary.credential_id,
)
).one()
if change == "revoked":
assert json.loads(encrypted) == {"access_token": "cipher:old-access", "refresh_token": "cipher:old-refresh"}
assert expiry == 1
grant = session.scalar(
select(CredentialPermission).where(
CredentialPermission.tenant_id == TENANT_ID,
CredentialPermission.credential_id == boundary.credential_id,
CredentialPermission.account_id == ACCOUNT_A,
CredentialPermission.credential_type == CredentialType.BUILTIN_TOOL_PROVIDER,
)
)
assert grant is not None
assert grant.has_permission is False
else:
assert json.loads(encrypted) == {
"access_token": "cipher:concurrent-access",
"refresh_token": "cipher:concurrent-refresh",
}
assert expiry == 4202444800
@pytest.fixture
def paused_native_draft(
native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch
) -> Iterator[ResumeEvidence]:
"""Real saved run/pause/context; only blob, Redis and final resume effects are external."""
from decimal import Decimal
from sqlalchemy import event
import core.app.apps.execution_coordinator as cancellation_owner
import repositories.sqlalchemy_api_workflow_run_repository as pause_owner
import tasks.app_generate.workflow_execute_task as worker
from core.app.app_config.entities import WorkflowUIBasedAppConfig
from core.app.apps.base_app_generator import BaseAppGenerator
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, WorkflowAppGenerateEntity
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext
from graphon.enums import WorkflowExecutionStatus
from graphon.runtime import RuntimeState, VariablePool
from models.enums import ConversationFromSource, CreatorUserRole, WorkflowRunTriggeredFrom
from models.model import Conversation, Message
from models.workflow import WorkflowPause, WorkflowRun
run_id = "eeeeeeee-0000-4000-8000-000000000001"
conversation_id = "eeeeeeee-0000-4000-8000-000000000002"
task_id = "eeeeeeee-0000-4000-8000-000000000003"
state_key = "resume-draft-tool-state.json"
state = ResumeEvidence(run_id=run_id, task_id=task_id)
class BlobBoundary:
@staticmethod
def load(key: str) -> bytes:
assert key == state_key
state.storage_loads.append(key)
return state.saved_blob
class RedisBoundary:
def __init__(self) -> None:
self.values: dict[str, bytes | list[bytes]] = {
"generate_task_stopped:eeeeeeee-0000-4000-8000-000000000003": b"1",
"workflow:eeeeeeee-0000-4000-8000-000000000003:commands:pending": b"1",
"workflow:eeeeeeee-0000-4000-8000-000000000003:commands": [],
}
def delete(self, key: str) -> int:
state.redis_calls.append(("delete", key))
return int(self.values.pop(key, None) is not None)
def get(self, key: str) -> bytes | list[bytes] | None:
state.redis_calls.append(("get", key))
return self.values.get(key)
def pipeline(self) -> "RedisPipeline":
state.redis_calls.append(("pipeline",))
return RedisPipeline(self)
class RedisPipeline:
def __init__(self, redis: RedisBoundary) -> None:
self.redis = redis
self.operations: list[tuple[str, str]] = []
def __enter__(self) -> Self:
return self
def __exit__(self, *_args: object) -> bool:
return False
def get(self, key: str) -> Self:
self.operations.append(("get", key))
return self
def delete(self, key: str) -> Self:
self.operations.append(("delete", key))
return self
def lrange(self, key: str, _start: int, _end: int) -> Self:
self.operations.append(("lrange", key))
return self
def execute(self) -> list[object]:
return [
self.redis.values.get(key, [])
if operation == "lrange"
else {"get": self.redis.get, "delete": self.redis.delete}[operation](key)
for operation, key in self.operations
]
redis = RedisBoundary()
monkeypatch.setattr(cancellation_owner, "redis_client", redis)
monkeypatch.setattr(pause_owner, "storage", BlobBoundary())
monkeypatch.setattr(worker, "db", DraftDatabase(engine=native_draft.tools.engine))
def observe_pause_write(
_connection: Connection, _cursor: object, statement: str, _params: object, _context: object, _many: bool
) -> None:
if statement.lstrip().lower().startswith(("update workflow_pauses ", "update workflow_runs ")):
state.pause_writes.append(statement)
def resume_boundary(
_self: object,
*,
application_generate_entity: WorkflowAppGenerateEntity | AdvancedChatAppGenerateEntity,
workflow: Workflow,
**_kwargs: object,
) -> Never:
entity = application_generate_entity
execution_id = (
entity.workflow_execution_id if isinstance(entity, WorkflowAppGenerateEntity) else entity.workflow_run_id
)
assert execution_id is not None
state.native_calls.append(execution_id)
# Keep the actual saved-graph restoration owner used by both native
# workers; the final generator boundary stops before business effects.
with Session(native_draft.tools.engine) as session:
BaseAppGenerator._restore_workflow_run_graph(
session=session,
workflow=workflow,
workflow_run_id=execution_id,
)
state.executed_graph = dict(workflow.graph_dict)
raise NativeDispatchReachedError("normal native resume reached before saved graph admission")
monkeypatch.setattr(worker.WorkflowAppGenerator, "resume", resume_boundary)
monkeypatch.setattr(worker.AdvancedChatAppGenerator, "resume", resume_boundary)
def save_context() -> None:
context = WorkflowResumptionContext(
generate_entity={"type": state.mode, "entity": state.entity},
serialized_graph_runtime_state=RuntimeState(
variable_pool=VariablePool(), start_at=0.0, workflow_id=DRAFT_ID
).dumps(),
)
state.saved_blob = context.dumps().encode()
def store(mode: AppMode, saved_graph: Graph | str | None) -> None:
state.mode = AppMode(mode)
native_draft.app.mode = state.mode
native_draft.workflow.type = WorkflowType.CHAT if state.mode == AppMode.ADVANCED_CHAT else WorkflowType.WORKFLOW
sqlite_session.add(native_draft.user)
run = WorkflowRun(
id=run_id,
tenant_id=TENANT_ID,
app_id=APP_ID,
workflow_id=DRAFT_ID,
type=native_draft.workflow.type,
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
version=Workflow.VERSION_DRAFT,
graph=json.dumps(saved_graph),
inputs="{}",
status=WorkflowExecutionStatus.PAUSED,
created_by_role=CreatorUserRole.ACCOUNT,
created_by=ACCOUNT_A,
)
pause = WorkflowPause(workflow_id=DRAFT_ID, workflow_run_id=run_id, state_object_key=state_key)
sqlite_session.add_all([run, pause])
app_config = WorkflowUIBasedAppConfig(
tenant_id=TENANT_ID,
app_id=APP_ID,
app_mode=state.mode,
workflow_id=DRAFT_ID,
)
invocation = {
"app_config": app_config,
"task_id": task_id,
"inputs": {},
"files": [],
"user_id": ACCOUNT_A,
"stream": True,
"invoke_from": InvokeFrom.DEBUGGER,
}
if state.mode == AppMode.WORKFLOW:
state.entity = WorkflowAppGenerateEntity(**invocation, workflow_execution_id=run_id)
else:
conversation = Conversation(
id=conversation_id,
app_id=APP_ID,
mode=AppMode.ADVANCED_CHAT,
name="Paused draft",
_inputs={},
from_source=ConversationFromSource.CONSOLE,
from_account_id=ACCOUNT_A,
invoke_from=ModelInvokeFrom.DEBUGGER,
)
message = Message(
id="eeeeeeee-0000-4000-8000-000000000004",
app_id=APP_ID,
conversation_id=conversation_id,
_inputs={},
query="hello",
message={"role": "user", "content": "hello"},
answer="",
message_unit_price=Decimal(0),
answer_unit_price=Decimal(0),
currency="USD",
from_source=ConversationFromSource.CONSOLE,
from_account_id=ACCOUNT_A,
invoke_from=ModelInvokeFrom.DEBUGGER,
workflow_run_id=run_id,
app_mode=AppMode.ADVANCED_CHAT,
)
sqlite_session.add_all([conversation, message])
state.entity = AdvancedChatAppGenerateEntity(
**invocation,
workflow_run_id=run_id,
conversation_id=conversation_id,
query="hello",
)
sqlite_session.commit()
state.run = run
save_context()
def assert_not_resumed() -> None:
assert state.storage_loads == ["resume-draft-tool-state.json"]
assert state.redis_calls == []
assert redis.values["generate_task_stopped:eeeeeeee-0000-4000-8000-000000000003"] == b"1"
assert state.pause_writes == []
assert state.native_calls == []
with Session(native_draft.tools.engine) as session:
saved_run = session.get(WorkflowRun, run_id)
assert saved_run is not None
assert saved_run.status == WorkflowExecutionStatus.PAUSED
saved_pause = session.scalar(select(WorkflowPause).where(WorkflowPause.workflow_run_id == run_id))
assert saved_pause is not None
assert saved_pause.resumed_at is None
assert native_draft.tools.forbidden == []
def resume() -> None:
# Tests deliberately commit saved-run edits before resuming. Measure
# only the actual resume attempt, retaining every SQL write it makes.
state.pause_writes.clear()
return worker._resume_app_execution({"workflow_run_id": run_id})
state.store = store
state.save_context = save_context
state.resume = resume
state.assert_not_resumed = assert_not_resumed
state.redis_values = redis.values
event.listen(native_draft.tools.engine, "before_cursor_execute", observe_pause_write)
try:
yield state
finally:
event.remove(native_draft.tools.engine, "before_cursor_execute", observe_pause_write)
@pytest.mark.parametrize("mode", ["workflow", "advanced-chat"])
@pytest.mark.parametrize("draft_edit", ["removed-tool", "changed-credential"])
def test_resume_refuses_saved_revoked_tool_before_any_resume_effect(
paused_native_draft: ResumeEvidence,
native_draft: NativeDraftCase,
sqlite_session: Session,
mode: AppMode,
draft_edit: str,
) -> None:
owner = Account(name="Saved Tool credential owner", email="saved-tool-owner@example.test")
owner.id = ACCOUNT_B
sqlite_session.add(owner)
sqlite_session.commit()
selected = add_credential(
sqlite_session,
tenant_id=TENANT_ID,
provider=AUTH_PROVIDER,
user_id=ACCOUNT_B,
visibility=PermissionEnum.PARTIAL_TEAM,
partial_members=[ACCOUNT_A],
name="Saved selected credential",
)
set_tool(native_draft, sqlite_session, credential_id=selected.id)
paused_native_draft.store(mode, dict(native_draft.workflow.graph_dict))
grant = sqlite_session.scalar(select(CredentialPermission).where(CredentialPermission.credential_id == selected.id))
assert grant is not None
grant.has_permission = False
if draft_edit == "removed-tool":
graph = native_draft.workflow.graph_dict
assert isinstance(graph, dict)
graph["nodes"] = [node for node in graph["nodes"] if node["id"] != "t"]
graph["edges"] = [{"id": "bypass", "source": "s", "target": "e"}]
native_draft.workflow.graph = json.dumps(graph)
sqlite_session.commit()
else:
alternative = add_credential(
sqlite_session,
tenant_id=TENANT_ID,
provider=AUTH_PROVIDER,
user_id=ACCOUNT_A,
name="Current draft accessible alternative",
)
set_tool(native_draft, sqlite_session, credential_id=alternative.id)
with pytest.raises(ToolProviderCredentialValidationError, match="Tool|Saved draft execution"):
paused_native_draft.resume()
paused_native_draft.assert_not_resumed()
@pytest.mark.parametrize("mode", ["workflow", "advanced-chat"])
def test_resume_configured_saved_graph_ignores_later_unconfigured_draft_tool(
paused_native_draft: ResumeEvidence,
native_draft: NativeDraftCase,
sqlite_session: Session,
mode: AppMode,
) -> None:
from tests.unit_tests.services.dify_builder.installed_tool_inventory import FakeTool, provider_payload
selected = add_credential(sqlite_session, tenant_id=TENANT_ID, provider=AUTH_PROVIDER, user_id=ACCOUNT_A)
set_tool(native_draft, sqlite_session, credential_id=selected.id)
saved_graph = dict(native_draft.workflow.graph_dict)
paused_native_draft.store(mode, saved_graph)
# A later draft contains a different installed but unconfigured Tool. The
# saved configured execution remains entitled to resume without regeneration.
native_draft.tools.daemon.providers_by_tenant[TENANT_ID].append(
provider_payload("acme/other", "other", [FakeTool("post", "Other post")], needs_credentials=True)
)
current_graph = native_draft.workflow.graph_dict
current_tool = current_graph["nodes"][1]["data"]
current_tool.update(
{
"provider_id": "acme/other/other",
"provider_name": "acme/other/other",
"tool_parameters": {},
"tool_label": "Other post",
}
)
current_tool.pop("credential_id", None)
native_draft.workflow.graph = json.dumps(current_graph)
sqlite_session.commit()
with pytest.raises(NativeDispatchReachedError):
paused_native_draft.resume()
assert paused_native_draft.executed_graph == saved_graph
assert paused_native_draft.native_calls == [paused_native_draft.run_id]
assert "generate_task_stopped:eeeeeeee-0000-4000-8000-000000000003" not in paused_native_draft.redis_values
assert paused_native_draft.pause_writes
assert native_draft.tools.forbidden == []
@pytest.mark.parametrize("mode", ["workflow", "advanced-chat"])
@pytest.mark.parametrize(
"saved_graph", [None, "not JSON", "[]", "{}"], ids=["absent", "malformed", "array", "missing-nodes"]
)
def test_resume_unverifiable_saved_graph_refuses_before_resume_effects(
paused_native_draft: ResumeEvidence,
native_draft: NativeDraftCase,
sqlite_session: Session,
mode: AppMode,
saved_graph: str | None,
) -> None:
selected = add_credential(sqlite_session, tenant_id=TENANT_ID, provider=AUTH_PROVIDER, user_id=ACCOUNT_A)
set_tool(native_draft, sqlite_session, credential_id=selected.id)
paused_native_draft.store(mode, dict(native_draft.workflow.graph_dict))
paused_native_draft.run.graph = saved_graph
sqlite_session.commit()
with pytest.raises(ToolProviderCredentialValidationError, match="Tool|Saved draft execution"):
paused_native_draft.resume()
paused_native_draft.assert_not_resumed()
@pytest.mark.parametrize("mode", ["workflow", "advanced-chat"])
@pytest.mark.parametrize("scope", ["run-tenant", "context-actor", "context-workflow", "context-run"])
def test_resume_saved_graph_requires_actual_run_and_invocation_owners(
paused_native_draft: ResumeEvidence,
native_draft: NativeDraftCase,
sqlite_session: Session,
mode: AppMode,
scope: str,
) -> None:
selected = add_credential(sqlite_session, tenant_id=TENANT_ID, provider=AUTH_PROVIDER, user_id=ACCOUNT_A)
set_tool(native_draft, sqlite_session, credential_id=selected.id)
paused_native_draft.store(mode, dict(native_draft.workflow.graph_dict))
if scope == "run-tenant":
paused_native_draft.run.tenant_id = OTHER_TENANT_ID
sqlite_session.commit()
elif scope == "context-actor":
paused_native_draft.entity.user_id = ACCOUNT_B
elif scope == "context-workflow":
paused_native_draft.entity.app_config.workflow_id = "dddddddd-0000-4000-8000-000000000002"
elif mode == "workflow":
assert isinstance(paused_native_draft.entity, WorkflowAppGenerateEntity)
paused_native_draft.entity.workflow_execution_id = "eeeeeeee-0000-4000-8000-000000000099"
else:
assert isinstance(paused_native_draft.entity, AdvancedChatAppGenerateEntity)
paused_native_draft.entity.workflow_run_id = "eeeeeeee-0000-4000-8000-000000000099"
paused_native_draft.save_context()
with pytest.raises(ToolProviderCredentialValidationError, match="Tool|Saved draft execution"):
paused_native_draft.resume()
paused_native_draft.assert_not_resumed()
@pytest.mark.parametrize("change", ["default-swap", "rotation-before-read"])
def test_builder_native_tool_refuses_changed_confirmed_credential_before_crypto(
native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, change: str
) -> None:
import hashlib
from sqlalchemy import event
from graphon.nodes.tool.exc import ToolRuntimeResolutionError
from models.tools import BuiltinToolProvider
credential = add_credential(sqlite_session, tenant_id=TENANT_ID, provider=AUTH_PROVIDER, user_id=ACCOUNT_A)
fingerprint = hashlib.sha256(
f"{credential.id}:{credential.updated_at}:{credential.encrypted_credentials}".encode()
).hexdigest()
node, pool = native_tool_node(native_draft, credential_bindings={"t": fingerprint})
if change == "default-swap":
credential.is_default = False
add_credential(
sqlite_session,
tenant_id=TENANT_ID,
provider=AUTH_PROVIDER,
user_id=ACCOUNT_A,
name="Different authorized account",
)
monkeypatch.setattr(ToolManager, "get_tool_runtime", classmethod(_NATIVE_TOOL_RUNTIME))
rotated: list[bool] = []
def rotate_before_value_read(state: ORMExecuteState) -> None:
descriptions = state.statement.column_descriptions if isinstance(state.statement, Select) else ()
if (
change == "rotation-before-read"
and not rotated
and descriptions
and descriptions[0].get("expr") is BuiltinToolProvider
):
rotated.append(True)
# A timestamp can retain second-level precision; bind ciphertext as well.
original_updated_at = credential.updated_at
credential.encrypted_credentials = '{"api_key":"changed"}'
credential.updated_at = original_updated_at
sqlite_session.commit()
event.listen(Session, "do_orm_execute", rotate_before_value_read)
try:
with pytest.raises(ToolRuntimeResolutionError, match="credential changed after confirmation"):
node._runtime.get_runtime(node_id="t", node_data=node.node_data, variable_pool=pool)
assert rotated == ([True] if change == "rotation-before-read" else [])
assert native_draft.tools.forbidden == []
finally:
event.remove(Session, "do_orm_execute", rotate_before_value_read)
@pytest.mark.parametrize("external_rotation", [False, True])
def test_builder_two_nodes_share_only_their_authorized_oauth_refresh_receipt(
native_draft_oauth: NativeOAuthCase, external_rotation: bool
) -> None:
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
from core.workflow.node_runtime import DifyToolNodeRuntime, _WorkflowToolRuntimeBinding
from models.tools import BuiltinToolProvider
from services.tools.draft_tool_setup import tool_credential_fingerprint
boundary = native_draft_oauth
with Session(boundary.engine) as session:
credential = session.get(BuiltinToolProvider, boundary.credential_id)
assert credential is not None
fingerprint = tool_credential_fingerprint(credential)
bindings = {"t": fingerprint, "second": fingerprint, "other-account": "unrelated-binding"}
node, pool = native_tool_node(boundary.case, credential_bindings=bindings)
first = node._runtime.get_runtime(node_id="t", node_data=node.node_data, variable_pool=pool)
assert isinstance(first.raw, _WorkflowToolRuntimeBinding)
assert first.raw.tool.runtime.credentials["access_token"] == "synthetic-new-access"
# A nested factory shares its DifyRunContext; serialization must preserve
# this run's trusted rotation for native pause/rebuild as well.
assert isinstance(node._runtime, DifyToolNodeRuntime)
context = node._runtime._run_context
assert context is not None
assert context.builder_tool_credential_bindings is not None
assert context.builder_tool_credential_bindings["second"] != fingerprint
assert context.builder_tool_credential_bindings["other-account"] == "unrelated-binding"
restored = DifyRunContext.model_validate_json(context.model_dump_json())
second_runtime = DifyToolNodeRuntime(
{DIFY_RUN_CONTEXT_KEY: restored},
workflow_id=DRAFT_ID,
)
if external_rotation:
from graphon.nodes.tool.exc import ToolRuntimeResolutionError
with Session(boundary.engine) as session, session.begin():
credential = session.get(BuiltinToolProvider, boundary.credential_id)
assert credential is not None
credential.encrypted_credentials = '{"access_token":"external-rotation"}'
with pytest.raises(ToolRuntimeResolutionError, match="credential changed after confirmation"):
second_runtime.get_runtime(node_id="second", node_data=node.node_data, variable_pool=pool)
else:
second = second_runtime.get_runtime(node_id="second", node_data=node.node_data, variable_pool=pool)
assert isinstance(second.raw, _WorkflowToolRuntimeBinding)
assert second.raw.tool.runtime.credentials["access_token"] == "synthetic-new-access"
assert len(boundary.refresh_calls) == 1
assert bindings["second"] == context.builder_tool_credential_bindings["second"]
assert SECRET_MARKER not in context.model_dump_json()
assert "synthetic-new-access" not in context.model_dump_json()