mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
refactor(dify-agent): download Binding files through Agent Stub (#40143)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
autofix-ci[bot]
parent
c094117ca2
commit
916d7e1c96
@@ -2,22 +2,23 @@ from __future__ import annotations
|
||||
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
|
||||
from dify_agent.protocol import WorkspaceListResponse, WorkspaceReadResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from dify_agent.protocol import BindingFileListResponse, BindingFileReadResponse
|
||||
|
||||
from controllers.console import agent_app_sandbox as module
|
||||
from models.model import App, AppMode, IconType
|
||||
from services.agent_app_sandbox_service import AgentSandboxInfo, AgentSandboxInspectorError, AgentSandboxUploadDownload
|
||||
from services.agent_app_sandbox_service import AgentSandboxDownload, AgentSandboxInfo, AgentSandboxInspectorError
|
||||
|
||||
|
||||
class _AgentAppService:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str, str, str, str, str, str, str]] = []
|
||||
|
||||
def resolve_app_id(self, *, tenant_id: str, agent_id: str) -> str:
|
||||
return "app-1"
|
||||
|
||||
def get_info(
|
||||
self,
|
||||
*,
|
||||
@@ -41,9 +42,9 @@ class _AgentAppService:
|
||||
caller_id: str,
|
||||
account_id: str,
|
||||
path: str,
|
||||
) -> WorkspaceListResponse:
|
||||
) -> BindingFileListResponse:
|
||||
self.calls.append(("list", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
|
||||
return WorkspaceListResponse(path=path, entries=[], truncated=False)
|
||||
return BindingFileListResponse(path=path, entries=[], truncated=False)
|
||||
|
||||
def read_file(
|
||||
self,
|
||||
@@ -55,11 +56,11 @@ class _AgentAppService:
|
||||
caller_id: str,
|
||||
account_id: str,
|
||||
path: str,
|
||||
) -> WorkspaceReadResponse:
|
||||
) -> BindingFileReadResponse:
|
||||
self.calls.append(("read", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
|
||||
return WorkspaceReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
|
||||
return BindingFileReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
|
||||
|
||||
def upload_file(
|
||||
def download_file(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
@@ -69,14 +70,17 @@ class _AgentAppService:
|
||||
caller_id: str,
|
||||
account_id: str,
|
||||
path: str,
|
||||
) -> AgentSandboxUploadDownload:
|
||||
self.calls.append(("upload", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
|
||||
return AgentSandboxUploadDownload(url="https://files.example/report.txt")
|
||||
) -> AgentSandboxDownload:
|
||||
self.calls.append(("download", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
|
||||
return AgentSandboxDownload(url="https://files.example/report.txt")
|
||||
|
||||
|
||||
class _WorkflowService:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str, str, str, str, str]] = []
|
||||
self.calls: list[tuple[str, ...]] = []
|
||||
|
||||
def resolve_app_id(self, *, tenant_id: str, app_id: str) -> str:
|
||||
return app_id
|
||||
|
||||
def list_files(
|
||||
self,
|
||||
@@ -88,9 +92,9 @@ class _WorkflowService:
|
||||
node_execution_id: str,
|
||||
path: str,
|
||||
session,
|
||||
) -> WorkspaceListResponse:
|
||||
) -> BindingFileListResponse:
|
||||
self.calls.append(("list", tenant_id, app_id, workflow_run_id, node_id, path))
|
||||
return WorkspaceListResponse(path=path, entries=[], truncated=False)
|
||||
return BindingFileListResponse(path=path, entries=[], truncated=False)
|
||||
|
||||
def read_file(
|
||||
self,
|
||||
@@ -102,11 +106,11 @@ class _WorkflowService:
|
||||
node_execution_id: str,
|
||||
path: str,
|
||||
session,
|
||||
) -> WorkspaceReadResponse:
|
||||
) -> BindingFileReadResponse:
|
||||
self.calls.append(("read", tenant_id, app_id, workflow_run_id, node_id, path))
|
||||
return WorkspaceReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
|
||||
return BindingFileReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
|
||||
|
||||
def upload_file(
|
||||
def download_file(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
@@ -114,11 +118,11 @@ class _WorkflowService:
|
||||
workflow_run_id: str,
|
||||
node_id: str,
|
||||
node_execution_id: str,
|
||||
account_id: str,
|
||||
path: str,
|
||||
session,
|
||||
) -> AgentSandboxUploadDownload:
|
||||
self.calls.append(("upload", tenant_id, app_id, workflow_run_id, node_id, path))
|
||||
return AgentSandboxUploadDownload(url="https://files.example/upload.txt")
|
||||
) -> AgentSandboxDownload:
|
||||
self.calls.append(("download", tenant_id, app_id, workflow_run_id, node_id, account_id, path))
|
||||
return AgentSandboxDownload(url="https://files.example/download.txt")
|
||||
|
||||
|
||||
def _app_model(app_id: str = "app-1") -> App:
|
||||
@@ -160,46 +164,40 @@ def test_handle_maps_sandbox_and_agent_backend_errors() -> None:
|
||||
module._handle(RuntimeError("boom"))
|
||||
|
||||
|
||||
def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch, unbound_session: Session) -> None:
|
||||
def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = _AgentAppService()
|
||||
session = unbound_session
|
||||
account = SimpleNamespace(id="account-1")
|
||||
resolver = MagicMock(return_value=_app_model())
|
||||
monkeypatch.setattr(module, "AgentAppSandboxService", lambda: service)
|
||||
monkeypatch.setattr(module, "resolve_agent_runtime_app_model", resolver)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"query_params_from_request",
|
||||
lambda model: SimpleNamespace(caller_type="build_draft", caller_id="build-1", path="sub/report.txt"),
|
||||
)
|
||||
|
||||
info = unwrap(module.AgentAppSandboxInfoResource.get)(object(), session, account, "tenant-1", "agent-1")
|
||||
listing = unwrap(module.AgentAppSandboxListResource.get)(object(), session, account, "tenant-1", "agent-1")
|
||||
preview = unwrap(module.AgentAppSandboxReadResource.get)(object(), session, account, "tenant-1", "agent-1")
|
||||
req_data = module.AgentSandboxUploadPayload.model_validate(
|
||||
info = unwrap(module.AgentAppSandboxInfoResource.get)(object(), account, "tenant-1", "agent-1")
|
||||
listing = unwrap(module.AgentAppSandboxListResource.get)(object(), account, "tenant-1", "agent-1")
|
||||
preview = unwrap(module.AgentAppSandboxReadResource.get)(object(), account, "tenant-1", "agent-1")
|
||||
req_data = module.AgentSandboxDownloadPayload.model_validate(
|
||||
{"caller_type": "build_draft", "caller_id": "build-1", "path": "report.txt"}
|
||||
)
|
||||
upload = unwrap(module.AgentAppSandboxUploadResource.post)(
|
||||
object(), req_data, session, account, "tenant-1", "agent-1"
|
||||
)
|
||||
download = unwrap(module.AgentAppSandboxDownloadResource.post)(object(), req_data, account, "tenant-1", "agent-1")
|
||||
|
||||
assert info == {"workspace_cwd": "."}
|
||||
assert listing["path"] == "sub/report.txt"
|
||||
assert preview["text"] == "hello"
|
||||
assert upload == {"url": "https://files.example/report.txt"}
|
||||
assert download == {"url": "https://files.example/report.txt"}
|
||||
assert service.calls == [
|
||||
("info", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", ""),
|
||||
("list", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "sub/report.txt"),
|
||||
("read", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "sub/report.txt"),
|
||||
("upload", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "report.txt"),
|
||||
("download", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "report.txt"),
|
||||
]
|
||||
assert all(call.kwargs["session"] is session for call in resolver.call_args_list)
|
||||
|
||||
|
||||
def test_agent_app_sandbox_resource_returns_normalized_errors(
|
||||
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
|
||||
) -> None:
|
||||
def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class FailingService:
|
||||
def resolve_app_id(self, **kwargs):
|
||||
return "app-1"
|
||||
|
||||
def get_info(self, **kwargs):
|
||||
raise AgentSandboxInspectorError("no_active_binding", "no active binding", status_code=404)
|
||||
|
||||
@@ -207,20 +205,18 @@ def test_agent_app_sandbox_resource_returns_normalized_errors(
|
||||
raise AgentSandboxInspectorError("no_active_binding", "no active binding", status_code=404)
|
||||
|
||||
monkeypatch.setattr(module, "AgentAppSandboxService", FailingService)
|
||||
session = unbound_session
|
||||
account = SimpleNamespace(id="account-1")
|
||||
monkeypatch.setattr(module, "resolve_agent_runtime_app_model", MagicMock(return_value=_app_model()))
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"query_params_from_request",
|
||||
lambda model: SimpleNamespace(caller_type="conversation", caller_id="conv-1", path="."),
|
||||
)
|
||||
|
||||
assert unwrap(module.AgentAppSandboxInfoResource.get)(object(), session, account, "tenant-1", "agent-1") == (
|
||||
assert unwrap(module.AgentAppSandboxInfoResource.get)(object(), account, "tenant-1", "agent-1") == (
|
||||
{"code": "no_active_binding", "message": "no active binding"},
|
||||
404,
|
||||
)
|
||||
assert unwrap(module.AgentAppSandboxListResource.get)(object(), session, account, "tenant-1", "agent-1") == (
|
||||
assert unwrap(module.AgentAppSandboxListResource.get)(object(), account, "tenant-1", "agent-1") == (
|
||||
{"code": "no_active_binding", "message": "no active binding"},
|
||||
404,
|
||||
)
|
||||
@@ -242,18 +238,19 @@ def test_workflow_agent_sandbox_resources_proxy_service(monkeypatch: pytest.Monk
|
||||
preview = unwrap(module.WorkflowAgentSandboxReadResource.get)(
|
||||
object(), "tenant-1", app_model, "run-1", "agent-node"
|
||||
)
|
||||
req_data = module.WorkflowAgentSandboxUploadPayload.model_validate(
|
||||
{"node_execution_id": "execution-1", "path": "upload.txt"}
|
||||
req_data = module.WorkflowAgentSandboxDownloadPayload.model_validate(
|
||||
{"node_execution_id": "execution-1", "path": "download.txt"}
|
||||
)
|
||||
upload = unwrap(module.WorkflowAgentSandboxUploadResource.post)(
|
||||
object(), req_data, "tenant-1", app_model, "run-1", "agent-node"
|
||||
account = SimpleNamespace(id="account-1")
|
||||
download = unwrap(module.WorkflowAgentSandboxDownloadResource.post)(
|
||||
object(), req_data, "tenant-1", account, "app-1", "run-1", "agent-node"
|
||||
)
|
||||
|
||||
assert listing["path"] == "out.txt"
|
||||
assert preview["text"] == "hello"
|
||||
assert upload == {"url": "https://files.example/upload.txt"}
|
||||
assert download == {"url": "https://files.example/download.txt"}
|
||||
assert service.calls == [
|
||||
("list", "tenant-1", "app-1", "run-1", "agent-node", "out.txt"),
|
||||
("read", "tenant-1", "app-1", "run-1", "agent-node", "out.txt"),
|
||||
("upload", "tenant-1", "app-1", "run-1", "agent-node", "upload.txt"),
|
||||
("download", "tenant-1", "app-1", "run-1", "agent-node", "account-1", "download.txt"),
|
||||
]
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import io
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
from inspect import unwrap
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
import controllers.files.upload as module
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from models import Account, TenantAccountJoin
|
||||
from models.account import AccountStatus
|
||||
|
||||
|
||||
def fake_request(args: dict, file=None):
|
||||
@@ -17,6 +21,22 @@ def fake_request(args: dict, file=None):
|
||||
)
|
||||
|
||||
|
||||
def _persist_account_memberships(session: Session) -> None:
|
||||
account = Account(name="Tenant member", email="member@example.com", status=AccountStatus.ACTIVE)
|
||||
account.id = "account-1"
|
||||
decoy = Account(name="Other tenant member", email="decoy@example.com", status=AccountStatus.ACTIVE)
|
||||
decoy.id = "account-outside-tenant"
|
||||
session.add_all(
|
||||
[
|
||||
account,
|
||||
decoy,
|
||||
TenantAccountJoin(tenant_id="tenant-1", account_id=account.id),
|
||||
TenantAccountJoin(tenant_id="tenant-other", account_id=decoy.id),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
class DummyUser:
|
||||
def __init__(self, user_id="user-1"):
|
||||
self.id = user_id
|
||||
@@ -33,6 +53,16 @@ class DummyFile:
|
||||
return self.stream.read()
|
||||
|
||||
|
||||
class RecordingStream(io.BytesIO):
|
||||
def __init__(self, content: bytes, events: list[str]):
|
||||
super().__init__(content)
|
||||
self.events = events
|
||||
|
||||
def read(self, *args, **kwargs):
|
||||
self.events.append("file-read")
|
||||
return super().read(*args, **kwargs)
|
||||
|
||||
|
||||
class DummyToolFile:
|
||||
def __init__(self, name="test.txt", mimetype="text/plain"):
|
||||
self.id = "file-id"
|
||||
@@ -94,6 +124,138 @@ class TestPluginUploadFileApi:
|
||||
assert tool_file_manager_instance.create_file_by_raw.call_args.kwargs["conversation_id"] == "conversation-1"
|
||||
mock_tool_file_manager.sign_file.assert_called_once_with(tool_file_id="file-id", extension=".docx")
|
||||
|
||||
@patch.object(module, "get_user")
|
||||
@patch.object(module, "ToolFileManager")
|
||||
@pytest.mark.parametrize("sqlite_session", [(Account, TenantAccountJoin)], indirect=True)
|
||||
def test_account_upload_preserves_signed_account_owner(
|
||||
self,
|
||||
mock_tool_file_manager,
|
||||
mock_get_user,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
_persist_account_memberships(sqlite_session)
|
||||
events: list[str] = []
|
||||
dummy_file = DummyFile(filename="report.pdf", mimetype="application/pdf", content=b"account-owned")
|
||||
dummy_file.stream = RecordingStream(b"account-owned", events)
|
||||
|
||||
@contextmanager
|
||||
def membership_session():
|
||||
events.append("membership-session-enter")
|
||||
try:
|
||||
yield sqlite_session
|
||||
finally:
|
||||
events.append("membership-session-exit")
|
||||
|
||||
monkeypatch.setattr(module.session_factory, "create_session", membership_session)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"request",
|
||||
fake_request(
|
||||
{
|
||||
"timestamp": "123",
|
||||
"nonce": "abc",
|
||||
"sign": "sig",
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "account-1",
|
||||
"user_from": "account",
|
||||
},
|
||||
file=dummy_file,
|
||||
),
|
||||
)
|
||||
tool_file_manager = mock_tool_file_manager.return_value
|
||||
tool_file_manager.create_file_by_raw.side_effect = lambda **_kwargs: (
|
||||
events.append("storage-create-file") or DummyToolFile(name="report.pdf", mimetype="application/pdf")
|
||||
)
|
||||
mock_tool_file_manager.sign_file.return_value = "signed-url"
|
||||
|
||||
with patch.object(
|
||||
module,
|
||||
"verify_plugin_file_signature",
|
||||
side_effect=lambda **_kwargs: events.append("signature-verify") or True,
|
||||
) as verify_signature:
|
||||
api = module.PluginUploadFileApi()
|
||||
result, status_code = unwrap(api.post)(api)
|
||||
|
||||
assert status_code == 201
|
||||
assert result["reference"] == build_file_reference(record_id="file-id")
|
||||
assert events == [
|
||||
"membership-session-enter",
|
||||
"membership-session-exit",
|
||||
"signature-verify",
|
||||
"file-read",
|
||||
"storage-create-file",
|
||||
]
|
||||
mock_get_user.assert_not_called()
|
||||
verify_signature.assert_called_once_with(
|
||||
filename="report.pdf",
|
||||
mimetype="application/pdf",
|
||||
tenant_id="tenant-1",
|
||||
user_id="account-1",
|
||||
conversation_id=None,
|
||||
user_from="account",
|
||||
timestamp="123",
|
||||
nonce="abc",
|
||||
sign="sig",
|
||||
)
|
||||
tool_file_manager.create_file_by_raw.assert_called_once_with(
|
||||
user_id="account-1",
|
||||
tenant_id="tenant-1",
|
||||
file_binary=b"account-owned",
|
||||
mimetype="application/pdf",
|
||||
filename="report.pdf",
|
||||
conversation_id=None,
|
||||
)
|
||||
|
||||
@patch.object(module, "verify_plugin_file_signature")
|
||||
@patch.object(module, "get_user")
|
||||
@patch.object(module, "ToolFileManager")
|
||||
@pytest.mark.parametrize("sqlite_session", [(Account, TenantAccountJoin)], indirect=True)
|
||||
def test_account_upload_rejects_owner_outside_tenant(
|
||||
self,
|
||||
mock_tool_file_manager,
|
||||
mock_get_user,
|
||||
mock_verify_signature,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
_persist_account_memberships(sqlite_session)
|
||||
events: list[str] = []
|
||||
|
||||
@contextmanager
|
||||
def membership_session():
|
||||
events.append("membership-session-enter")
|
||||
try:
|
||||
yield sqlite_session
|
||||
finally:
|
||||
events.append("membership-session-exit")
|
||||
|
||||
monkeypatch.setattr(module.session_factory, "create_session", membership_session)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"request",
|
||||
fake_request(
|
||||
{
|
||||
"timestamp": "123",
|
||||
"nonce": "abc",
|
||||
"sign": "sig",
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "account-outside-tenant",
|
||||
"user_from": "account",
|
||||
},
|
||||
file=DummyFile(),
|
||||
),
|
||||
)
|
||||
|
||||
api = module.PluginUploadFileApi()
|
||||
with pytest.raises(Forbidden):
|
||||
unwrap(api.post)(api)
|
||||
|
||||
assert events == ["membership-session-enter", "membership-session-exit"]
|
||||
mock_get_user.assert_not_called()
|
||||
mock_verify_signature.assert_not_called()
|
||||
mock_tool_file_manager.assert_not_called()
|
||||
|
||||
def test_missing_file(self):
|
||||
module.request = fake_request(
|
||||
{
|
||||
|
||||
@@ -7,7 +7,11 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.inner_api.agent.files import AgentFileDownloadRequestApi, AgentFileUploadRequestApi
|
||||
from controllers.inner_api.agent.files import (
|
||||
AgentFileDownloadRequestApi,
|
||||
AgentFileRequestHttpError,
|
||||
AgentFileUploadRequestApi,
|
||||
)
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from services.file_request_service import DownloadFileRequestResult
|
||||
|
||||
@@ -46,9 +50,74 @@ def test_upload_request_returns_origin_free_uri(app: Flask) -> None:
|
||||
tenant_id="tenant-1",
|
||||
user_id="canonical-end-user-1",
|
||||
conversation_id="conversation-1",
|
||||
user_from=None,
|
||||
)
|
||||
|
||||
|
||||
def test_upload_request_preserves_tenant_scoped_account_owner(app: Flask) -> None:
|
||||
payload = {
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "account-1",
|
||||
"user_from": "account",
|
||||
"filename": "report.pdf",
|
||||
"mimetype": "application/pdf",
|
||||
"conversation_id": "conversation-1",
|
||||
}
|
||||
tenant = SimpleNamespace(id="tenant-1")
|
||||
session = MagicMock()
|
||||
with app.test_request_context("/", method="POST", json=payload):
|
||||
with (
|
||||
patch(f"{MODULE}.TenantService") as tenant_service,
|
||||
patch(f"{MODULE}.get_user") as get_user,
|
||||
patch(f"{MODULE}.get_signed_file_uri_for_plugin", return_value="/files/upload/for-plugin?sign=1") as sign,
|
||||
):
|
||||
tenant_service.get_tenant_by_id.return_value = tenant
|
||||
tenant_service.account_belongs_to_tenant.return_value = True
|
||||
response = _raw(AgentFileUploadRequestApi.post)(AgentFileUploadRequestApi(), session)
|
||||
|
||||
assert response == {"upload_uri": "/files/upload/for-plugin?sign=1"}
|
||||
get_user.assert_not_called()
|
||||
tenant_service.account_belongs_to_tenant.assert_called_once_with("account-1", "tenant-1", session=session)
|
||||
sign.assert_called_once_with(
|
||||
filename="report.pdf",
|
||||
mimetype="application/pdf",
|
||||
tenant_id="tenant-1",
|
||||
user_id="account-1",
|
||||
conversation_id="conversation-1",
|
||||
user_from="account",
|
||||
)
|
||||
|
||||
|
||||
def test_upload_request_rejects_account_outside_tenant_without_signing(app: Flask) -> None:
|
||||
payload = {
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "account-outside-tenant",
|
||||
"user_from": "account",
|
||||
"filename": "report.pdf",
|
||||
"mimetype": "application/pdf",
|
||||
}
|
||||
tenant = SimpleNamespace(id="tenant-1")
|
||||
session = MagicMock()
|
||||
with app.test_request_context("/", method="POST", json=payload):
|
||||
with (
|
||||
patch(f"{MODULE}.TenantService") as tenant_service,
|
||||
patch(f"{MODULE}.get_user") as get_user,
|
||||
patch(f"{MODULE}.get_signed_file_uri_for_plugin") as sign,
|
||||
):
|
||||
tenant_service.get_tenant_by_id.return_value = tenant
|
||||
tenant_service.account_belongs_to_tenant.return_value = False
|
||||
with pytest.raises(AgentFileRequestHttpError) as exc_info:
|
||||
_raw(AgentFileUploadRequestApi.post)(AgentFileUploadRequestApi(), session)
|
||||
|
||||
assert exc_info.value.error_code == "user_not_found"
|
||||
assert exc_info.value.code == 404
|
||||
tenant_service.account_belongs_to_tenant.assert_called_once_with(
|
||||
"account-outside-tenant", "tenant-1", session=session
|
||||
)
|
||||
get_user.assert_not_called()
|
||||
sign.assert_not_called()
|
||||
|
||||
|
||||
def test_download_request_returns_origin_free_uri_for_sandbox(app: Flask) -> None:
|
||||
reference = build_file_reference(record_id="tool-file-1")
|
||||
payload = {
|
||||
|
||||
@@ -187,6 +187,36 @@ def test_get_signed_file_uri_for_plugin_and_verify_roundtrip(monkeypatch: pytest
|
||||
)
|
||||
|
||||
|
||||
def test_plugin_upload_signature_binds_account_user_from(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
|
||||
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x09" * 16)
|
||||
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
|
||||
monkeypatch.setattr("core.tools.signature.dify_config.FILES_ACCESS_TIMEOUT", 60)
|
||||
|
||||
uri = get_signed_file_uri_for_plugin(
|
||||
filename="report.pdf",
|
||||
mimetype="application/pdf",
|
||||
tenant_id="tenant-id",
|
||||
user_id="account-id",
|
||||
user_from="account",
|
||||
)
|
||||
query = parse_qs(urlparse(uri).query)
|
||||
|
||||
assert query["user_from"] == ["account"]
|
||||
signed = {
|
||||
"filename": "report.pdf",
|
||||
"mimetype": "application/pdf",
|
||||
"tenant_id": "tenant-id",
|
||||
"user_id": "account-id",
|
||||
"timestamp": query["timestamp"][0],
|
||||
"nonce": query["nonce"][0],
|
||||
"sign": query["sign"][0],
|
||||
}
|
||||
assert verify_plugin_file_signature(**signed, user_from="account") is True
|
||||
assert verify_plugin_file_signature(**signed, user_from="end-user") is False
|
||||
assert verify_plugin_file_signature(**signed) is False
|
||||
|
||||
|
||||
def test_verify_plugin_file_signature_rejects_invalid_signatures(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
|
||||
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x07" * 16)
|
||||
|
||||
@@ -199,9 +199,13 @@ def test_load_or_create_persists_binding_on_node_execution(monkeypatch, home_sna
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert resolved.id == "binding-1"
|
||||
assert resolved.backend_binding_ref == "backend-binding-1"
|
||||
assert resolved.agent_id == "agent-1"
|
||||
assert resolved.agent_config_version_id == "config-1"
|
||||
assert resolved.agent_config_version_kind == "snapshot"
|
||||
owner_scope = get_active.call_args.kwargs["expected_owner_scope"]
|
||||
assert owner_scope.owner_scope_key == "node-1:workflow-binding-1"
|
||||
session.rollback.assert_called_once_with()
|
||||
|
||||
|
||||
def test_load_existing_pointer_rejects_missing_workflow_identity(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
from contextlib import nullcontext
|
||||
import json
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from dify_agent.protocol import WorkspaceListResponse, WorkspaceReadResponse
|
||||
from dify_agent.client import Client
|
||||
from dify_agent.protocol import BindingFileDownloadResponse, BindingFileListResponse, BindingFileReadResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from graphon.enums import WorkflowNodeExecutionStatus
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigVersionKind,
|
||||
AgentKind,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentStatus,
|
||||
AgentWorkingResourceStatus,
|
||||
AgentWorkspace,
|
||||
AgentWorkspaceBinding,
|
||||
AgentWorkspaceOwnerType,
|
||||
)
|
||||
from models.enums import ConversationFromSource
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.enums import ConversationFromSource, CreatorUserRole
|
||||
from models.model import App, AppMode, Conversation, IconType
|
||||
from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom
|
||||
from services import agent_app_sandbox_service as sandbox_module
|
||||
from services.agent.workspace_service import AgentWorkspaceService
|
||||
from services.agent_app_sandbox_service import (
|
||||
AgentAppSandboxService,
|
||||
AgentSandboxDownload,
|
||||
AgentSandboxInspectorError,
|
||||
WorkflowAgentSandboxService,
|
||||
)
|
||||
@@ -104,6 +119,79 @@ def _use_session(monkeypatch: pytest.MonkeyPatch, session: Session) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _add_app(session: Session, *, app_id: str, tenant_id: str) -> None:
|
||||
session.add(
|
||||
App(
|
||||
id=app_id,
|
||||
tenant_id=tenant_id,
|
||||
name=f"App {app_id}",
|
||||
description="",
|
||||
mode=AppMode.AGENT,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="robot",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=False,
|
||||
enable_api=False,
|
||||
max_active_requests=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _add_binding(
|
||||
session: Session,
|
||||
*,
|
||||
binding_id: str,
|
||||
workspace_id: str,
|
||||
tenant_id: str = "tenant-1",
|
||||
app_id: str = "app-1",
|
||||
agent_id: str = "agent-1",
|
||||
owner_type: AgentWorkspaceOwnerType,
|
||||
owner_id: str,
|
||||
owner_scope_key: str = "root",
|
||||
status: AgentWorkingResourceStatus = AgentWorkingResourceStatus.ACTIVE,
|
||||
) -> AgentWorkspaceBinding:
|
||||
active_guard = 1 if status is AgentWorkingResourceStatus.ACTIVE else None
|
||||
workspace = AgentWorkspace(
|
||||
id=workspace_id,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
owner_type=owner_type,
|
||||
owner_id=owner_id,
|
||||
owner_scope_key=owner_scope_key,
|
||||
backend_workspace_ref=f"{workspace_id}-ref",
|
||||
status=status,
|
||||
active_guard=active_guard,
|
||||
)
|
||||
binding = AgentWorkspaceBinding(
|
||||
id=binding_id,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
workspace_id=workspace_id,
|
||||
agent_id=agent_id,
|
||||
base_home_snapshot_id=None,
|
||||
agent_config_version_id=f"{binding_id}-config",
|
||||
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
|
||||
backend_binding_ref=f"{binding_id}-ref",
|
||||
status=status,
|
||||
)
|
||||
session.add_all([workspace, binding])
|
||||
return binding
|
||||
|
||||
|
||||
def _download_client() -> MagicMock:
|
||||
client = MagicMock()
|
||||
client.download_binding_file_sync.return_value = BindingFileDownloadResponse(reference="dify-file-ref:canonical")
|
||||
return client
|
||||
|
||||
|
||||
def _stub_download_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
sandbox_module,
|
||||
"_download_response",
|
||||
lambda **_kwargs: AgentSandboxDownload(url="https://files.example/report.txt"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(AgentWorkspace, AgentWorkspaceBinding, App, Conversation)],
|
||||
@@ -117,8 +205,8 @@ def test_agent_app_file_browsing_uses_conversation_pointer(
|
||||
sqlite_session.commit()
|
||||
_use_session(monkeypatch, sqlite_session)
|
||||
client = MagicMock()
|
||||
response = WorkspaceListResponse(path=".", entries=[], truncated=False)
|
||||
client.list_workspace_files_sync.return_value = response
|
||||
response = BindingFileListResponse(path=".", entries=[], truncated=False)
|
||||
client.list_binding_files_sync.return_value = response
|
||||
|
||||
result = AgentAppSandboxService(client_factory=lambda: nullcontext(client)).list_files(
|
||||
tenant_id="tenant-1",
|
||||
@@ -131,7 +219,7 @@ def test_agent_app_file_browsing_uses_conversation_pointer(
|
||||
)
|
||||
|
||||
assert result is response
|
||||
client.list_workspace_files_sync.assert_called_once_with(expected.backend_binding_ref, ".")
|
||||
client.list_binding_files_sync.assert_called_once_with(expected.backend_binding_ref, ".")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -160,7 +248,443 @@ def test_agent_app_file_browsing_rejects_other_account(
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "no_active_binding"
|
||||
client.list_workspace_files_sync.assert_not_called()
|
||||
client.list_binding_files_sync.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(AgentWorkspace, AgentWorkspaceBinding, App, Conversation)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_agent_conversation_download_resolves_only_exact_active_owner_chain(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
_add_app(sqlite_session, app_id="app-1", tenant_id="tenant-1")
|
||||
_add_app(sqlite_session, app_id="app-other", tenant_id="tenant-other")
|
||||
valid = _add_binding(
|
||||
sqlite_session,
|
||||
binding_id="binding-valid",
|
||||
workspace_id="workspace-valid",
|
||||
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
|
||||
owner_id="conversation-valid",
|
||||
)
|
||||
wrong_owner = _add_binding(
|
||||
sqlite_session,
|
||||
binding_id="binding-wrong-owner",
|
||||
workspace_id="workspace-wrong-owner",
|
||||
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
|
||||
owner_id="conversation-not-the-caller",
|
||||
)
|
||||
retired = _add_binding(
|
||||
sqlite_session,
|
||||
binding_id="binding-retired",
|
||||
workspace_id="workspace-retired",
|
||||
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
|
||||
owner_id="conversation-retired",
|
||||
status=AgentWorkingResourceStatus.RETIRED,
|
||||
)
|
||||
cross_tenant = _add_binding(
|
||||
sqlite_session,
|
||||
binding_id="binding-cross-tenant",
|
||||
workspace_id="workspace-cross-tenant",
|
||||
tenant_id="tenant-other",
|
||||
app_id="app-other",
|
||||
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
|
||||
owner_id="conversation-cross-tenant",
|
||||
)
|
||||
conversations = [
|
||||
Conversation(
|
||||
id="conversation-valid",
|
||||
app_id="app-1",
|
||||
mode=AppMode.AGENT,
|
||||
name="Valid",
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id="account-1",
|
||||
is_deleted=False,
|
||||
agent_workspace_binding_id=valid.id,
|
||||
),
|
||||
Conversation(
|
||||
id="conversation-wrong-owner",
|
||||
app_id="app-1",
|
||||
mode=AppMode.AGENT,
|
||||
name="Wrong owner",
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id="account-1",
|
||||
is_deleted=False,
|
||||
agent_workspace_binding_id=wrong_owner.id,
|
||||
),
|
||||
Conversation(
|
||||
id="conversation-retired",
|
||||
app_id="app-1",
|
||||
mode=AppMode.AGENT,
|
||||
name="Retired",
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id="account-1",
|
||||
is_deleted=False,
|
||||
agent_workspace_binding_id=retired.id,
|
||||
),
|
||||
Conversation(
|
||||
id="conversation-cross-tenant",
|
||||
app_id="app-other",
|
||||
mode=AppMode.AGENT,
|
||||
name="Cross tenant",
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id="account-other",
|
||||
is_deleted=False,
|
||||
agent_workspace_binding_id=cross_tenant.id,
|
||||
),
|
||||
]
|
||||
for conversation in conversations:
|
||||
conversation._inputs = {}
|
||||
sqlite_session.add_all(conversations)
|
||||
sqlite_session.commit()
|
||||
_use_session(monkeypatch, sqlite_session)
|
||||
_stub_download_response(monkeypatch)
|
||||
client = _download_client()
|
||||
service = AgentAppSandboxService(client_factory=lambda: nullcontext(cast(Client, client)))
|
||||
|
||||
result = service.download_file(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
agent_id="agent-1",
|
||||
caller_type="conversation",
|
||||
caller_id="conversation-valid",
|
||||
account_id="account-1",
|
||||
path="report.txt",
|
||||
)
|
||||
|
||||
assert result.url == "https://files.example/report.txt"
|
||||
request = client.download_binding_file_sync.call_args.args[0]
|
||||
assert request.backend_binding_ref == "binding-valid-ref"
|
||||
client.download_binding_file_sync.reset_mock()
|
||||
|
||||
rejected_locators = [
|
||||
{"account_id": "account-other"},
|
||||
{"app_id": "app-other"},
|
||||
{"caller_id": "conversation-wrong-owner"},
|
||||
{"caller_id": "conversation-retired"},
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"app_id": "app-other",
|
||||
"caller_id": "conversation-cross-tenant",
|
||||
"account_id": "account-other",
|
||||
},
|
||||
]
|
||||
for override in rejected_locators:
|
||||
locator = {
|
||||
"tenant_id": "tenant-1",
|
||||
"app_id": "app-1",
|
||||
"agent_id": "agent-1",
|
||||
"caller_type": "conversation",
|
||||
"caller_id": "conversation-valid",
|
||||
"account_id": "account-1",
|
||||
"path": "report.txt",
|
||||
}
|
||||
locator.update(override)
|
||||
with pytest.raises(AgentSandboxInspectorError, match="active Agent Workspace Binding"):
|
||||
service.download_file(**locator) # type: ignore[arg-type]
|
||||
|
||||
client.download_binding_file_sync.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(Agent, AgentConfigDraft, AgentWorkspace, AgentWorkspaceBinding)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_agent_build_draft_download_resolves_only_exact_active_owner_chain(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
sqlite_session.add_all(
|
||||
[
|
||||
Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Agent",
|
||||
description="",
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
app_id="app-1",
|
||||
status=AgentStatus.ACTIVE,
|
||||
),
|
||||
Agent(
|
||||
id="agent-cross-tenant",
|
||||
tenant_id="tenant-other",
|
||||
name="Other tenant Agent",
|
||||
description="",
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
app_id="app-other",
|
||||
status=AgentStatus.ACTIVE,
|
||||
),
|
||||
]
|
||||
)
|
||||
valid = _add_binding(
|
||||
sqlite_session,
|
||||
binding_id="binding-build-valid",
|
||||
workspace_id="workspace-build-valid",
|
||||
owner_type=AgentWorkspaceOwnerType.BUILD_DRAFT,
|
||||
owner_id="draft-valid",
|
||||
)
|
||||
wrong_owner = _add_binding(
|
||||
sqlite_session,
|
||||
binding_id="binding-build-wrong-owner",
|
||||
workspace_id="workspace-build-wrong-owner",
|
||||
owner_type=AgentWorkspaceOwnerType.BUILD_DRAFT,
|
||||
owner_id="draft-not-the-caller",
|
||||
)
|
||||
retired = _add_binding(
|
||||
sqlite_session,
|
||||
binding_id="binding-build-retired",
|
||||
workspace_id="workspace-build-retired",
|
||||
owner_type=AgentWorkspaceOwnerType.BUILD_DRAFT,
|
||||
owner_id="draft-retired",
|
||||
status=AgentWorkingResourceStatus.RETIRED,
|
||||
)
|
||||
drafts = [
|
||||
AgentConfigDraft(
|
||||
id="draft-valid",
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id="account-1",
|
||||
draft_owner_key="account-1",
|
||||
agent_workspace_binding_id=valid.id,
|
||||
config_snapshot=AgentSoulConfig(),
|
||||
),
|
||||
AgentConfigDraft(
|
||||
id="draft-wrong-owner",
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id="account-2",
|
||||
draft_owner_key="account-2",
|
||||
agent_workspace_binding_id=wrong_owner.id,
|
||||
config_snapshot=AgentSoulConfig(),
|
||||
),
|
||||
AgentConfigDraft(
|
||||
id="draft-retired",
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id="account-3",
|
||||
draft_owner_key="account-3",
|
||||
agent_workspace_binding_id=retired.id,
|
||||
config_snapshot=AgentSoulConfig(),
|
||||
),
|
||||
AgentConfigDraft(
|
||||
id="draft-cross-tenant",
|
||||
tenant_id="tenant-other",
|
||||
agent_id="agent-cross-tenant",
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id="account-other",
|
||||
draft_owner_key="account-other",
|
||||
agent_workspace_binding_id=None,
|
||||
config_snapshot=AgentSoulConfig(),
|
||||
),
|
||||
]
|
||||
sqlite_session.add_all(drafts)
|
||||
sqlite_session.commit()
|
||||
_use_session(monkeypatch, sqlite_session)
|
||||
_stub_download_response(monkeypatch)
|
||||
client = _download_client()
|
||||
service = AgentAppSandboxService(client_factory=lambda: nullcontext(cast(Client, client)))
|
||||
|
||||
result = service.download_file(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
agent_id="agent-1",
|
||||
caller_type="build_draft",
|
||||
caller_id="draft-valid",
|
||||
account_id="account-1",
|
||||
path="report.txt",
|
||||
)
|
||||
|
||||
assert result.url == "https://files.example/report.txt"
|
||||
assert client.download_binding_file_sync.call_args.args[0].backend_binding_ref == "binding-build-valid-ref"
|
||||
client.download_binding_file_sync.reset_mock()
|
||||
|
||||
rejected_locators = [
|
||||
{"account_id": "account-other"},
|
||||
{"app_id": "app-other"},
|
||||
{"caller_id": "draft-wrong-owner", "account_id": "account-2"},
|
||||
{"caller_id": "draft-retired", "account_id": "account-3"},
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"app_id": "app-other",
|
||||
"agent_id": "agent-cross-tenant",
|
||||
"caller_id": "draft-cross-tenant",
|
||||
"account_id": "account-other",
|
||||
},
|
||||
]
|
||||
for override in rejected_locators:
|
||||
locator = {
|
||||
"tenant_id": "tenant-1",
|
||||
"app_id": "app-1",
|
||||
"agent_id": "agent-1",
|
||||
"caller_type": "build_draft",
|
||||
"caller_id": "draft-valid",
|
||||
"account_id": "account-1",
|
||||
"path": "report.txt",
|
||||
}
|
||||
locator.update(override)
|
||||
with pytest.raises(AgentSandboxInspectorError, match="active Agent Workspace Binding"):
|
||||
service.download_file(**locator) # type: ignore[arg-type]
|
||||
|
||||
client.download_binding_file_sync.assert_not_called()
|
||||
|
||||
|
||||
def _workflow_execution(
|
||||
*,
|
||||
execution_id: str,
|
||||
tenant_id: str = "tenant-1",
|
||||
app_id: str = "app-1",
|
||||
workflow_run_id: str = "run-1",
|
||||
node_id: str = "node-1",
|
||||
binding_id: str,
|
||||
created_by: str = "historical-account",
|
||||
) -> WorkflowNodeExecutionModel:
|
||||
return WorkflowNodeExecutionModel(
|
||||
id=execution_id,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
workflow_id="workflow-1",
|
||||
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
||||
workflow_run_id=workflow_run_id,
|
||||
index=1,
|
||||
node_id=node_id,
|
||||
node_type="agent",
|
||||
title=node_id,
|
||||
agent_workspace_binding_id=binding_id,
|
||||
inputs=None,
|
||||
process_data=json.dumps({"workflow_agent_binding_id": "workflow-binding-1"}),
|
||||
outputs=None,
|
||||
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
||||
error=None,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(WorkflowNodeExecutionModel, AgentWorkspace, AgentWorkspaceBinding)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_workflow_download_resolves_only_exact_active_owner_chain(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
valid = _add_binding(
|
||||
sqlite_session,
|
||||
binding_id="binding-workflow-valid",
|
||||
workspace_id="workspace-workflow-valid",
|
||||
owner_type=AgentWorkspaceOwnerType.WORKFLOW_RUN,
|
||||
owner_id="run-1",
|
||||
owner_scope_key="node-1:workflow-binding-1",
|
||||
)
|
||||
wrong_owner = _add_binding(
|
||||
sqlite_session,
|
||||
binding_id="binding-workflow-wrong-owner",
|
||||
workspace_id="workspace-workflow-wrong-owner",
|
||||
owner_type=AgentWorkspaceOwnerType.WORKFLOW_RUN,
|
||||
owner_id="run-not-the-caller",
|
||||
owner_scope_key="node-1:workflow-binding-1",
|
||||
)
|
||||
retired = AgentWorkspaceBinding(
|
||||
id="binding-workflow-retired",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workspace_id=valid.workspace_id,
|
||||
agent_id="agent-1",
|
||||
base_home_snapshot_id=None,
|
||||
agent_config_version_id="binding-workflow-retired-config",
|
||||
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
|
||||
backend_binding_ref="binding-workflow-retired-ref",
|
||||
status=AgentWorkingResourceStatus.RETIRED,
|
||||
)
|
||||
sqlite_session.add(retired)
|
||||
sqlite_session.add_all(
|
||||
[
|
||||
_workflow_execution(execution_id="execution-valid", binding_id=valid.id),
|
||||
_workflow_execution(
|
||||
execution_id="execution-cross-tenant",
|
||||
tenant_id="tenant-other",
|
||||
binding_id=valid.id,
|
||||
),
|
||||
_workflow_execution(execution_id="execution-wrong-app", app_id="app-other", binding_id=valid.id),
|
||||
_workflow_execution(execution_id="execution-wrong-run", workflow_run_id="run-other", binding_id=valid.id),
|
||||
_workflow_execution(execution_id="execution-wrong-node", node_id="node-other", binding_id=valid.id),
|
||||
_workflow_execution(execution_id="execution-wrong-owner", binding_id=wrong_owner.id),
|
||||
_workflow_execution(execution_id="execution-retired", binding_id=retired.id),
|
||||
]
|
||||
)
|
||||
sqlite_session.commit()
|
||||
persisted_execution = sqlite_session.get(WorkflowNodeExecutionModel, "execution-valid")
|
||||
assert persisted_execution is not None
|
||||
assert persisted_execution.created_by == "historical-account"
|
||||
_use_session(monkeypatch, sqlite_session)
|
||||
client = _download_client()
|
||||
request_download = MagicMock(
|
||||
return_value=SimpleNamespace(download_uri="/files/tools/report.txt?timestamp=1&sign=2")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sandbox_module,
|
||||
"FileRequestService",
|
||||
lambda: SimpleNamespace(request_download=request_download),
|
||||
)
|
||||
monkeypatch.setattr(sandbox_module.dify_config, "FILES_URL", "https://files.example")
|
||||
service = WorkflowAgentSandboxService(client_factory=lambda: nullcontext(cast(Client, client)))
|
||||
|
||||
result = service.download_file(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_run_id="run-1",
|
||||
node_id="node-1",
|
||||
node_execution_id="execution-valid",
|
||||
account_id="authenticated-account",
|
||||
path="report.txt",
|
||||
)
|
||||
|
||||
assert result.url == "https://files.example/files/tools/report.txt?timestamp=1&sign=2&as_attachment=true"
|
||||
request = client.download_binding_file_sync.call_args.args[0]
|
||||
assert request.backend_binding_ref == "binding-workflow-valid-ref"
|
||||
assert request.execution_context.user_id == "authenticated-account"
|
||||
assert request.execution_context.user_from == "account"
|
||||
request_download.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
user_id="authenticated-account",
|
||||
user_from="account",
|
||||
invoke_from="debugger",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:canonical"},
|
||||
)
|
||||
client.download_binding_file_sync.reset_mock()
|
||||
|
||||
for node_execution_id in (
|
||||
"execution-cross-tenant",
|
||||
"execution-wrong-app",
|
||||
"execution-wrong-run",
|
||||
"execution-wrong-node",
|
||||
"execution-wrong-owner",
|
||||
"execution-retired",
|
||||
):
|
||||
with pytest.raises(AgentSandboxInspectorError, match="active Workspace Binding"):
|
||||
service.download_file(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_run_id="run-1",
|
||||
node_id="node-1",
|
||||
node_execution_id=node_execution_id,
|
||||
account_id="authenticated-account",
|
||||
path="report.txt",
|
||||
)
|
||||
|
||||
client.download_binding_file_sync.assert_not_called()
|
||||
assert request_download.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -185,12 +709,14 @@ def test_agent_app_file_browsing_uses_build_draft_caller(
|
||||
binding = SimpleNamespace(
|
||||
agent_id="agent-1",
|
||||
backend_binding_ref="binding-build-ref",
|
||||
agent_config_version_id="config-1",
|
||||
agent_config_version_kind=AgentConfigVersionKind.DRAFT,
|
||||
)
|
||||
get_binding = MagicMock(return_value=binding)
|
||||
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_binding)
|
||||
client = MagicMock()
|
||||
response = WorkspaceListResponse(path=".", entries=[], truncated=False)
|
||||
client.list_workspace_files_sync.return_value = response
|
||||
response = BindingFileListResponse(path=".", entries=[], truncated=False)
|
||||
client.list_binding_files_sync.return_value = response
|
||||
|
||||
result = AgentAppSandboxService(client_factory=lambda: nullcontext(client)).list_files(
|
||||
tenant_id="tenant-1",
|
||||
@@ -207,7 +733,7 @@ def test_agent_app_file_browsing_uses_build_draft_caller(
|
||||
assert owner_scope.app_id == runtime_app_id
|
||||
assert owner_scope.owner_type is AgentWorkspaceOwnerType.BUILD_DRAFT
|
||||
assert owner_scope.owner_id == "build-1"
|
||||
client.list_workspace_files_sync.assert_called_once_with("binding-build-ref", ".")
|
||||
client.list_binding_files_sync.assert_called_once_with("binding-build-ref", ".")
|
||||
|
||||
|
||||
def test_workflow_file_access_uses_node_execution_pointer(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -217,12 +743,17 @@ def test_workflow_file_access_uses_node_execution_pointer(monkeypatch: pytest.Mo
|
||||
)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = execution
|
||||
binding = SimpleNamespace(backend_binding_ref="binding-workflow-ref")
|
||||
binding = SimpleNamespace(
|
||||
agent_id="agent-1",
|
||||
backend_binding_ref="binding-workflow-ref",
|
||||
agent_config_version_id="config-1",
|
||||
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
|
||||
)
|
||||
get_binding = MagicMock(return_value=binding)
|
||||
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_binding)
|
||||
client = MagicMock()
|
||||
response = WorkspaceReadResponse(path="report.txt", size=2, truncated=False, binary=False, text="ok")
|
||||
client.read_workspace_file_sync.return_value = response
|
||||
response = BindingFileReadResponse(path="report.txt", size=2, truncated=False, binary=False, text="ok")
|
||||
client.read_binding_file_sync.return_value = response
|
||||
|
||||
result = WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).read_file(
|
||||
tenant_id="tenant-1",
|
||||
@@ -236,4 +767,238 @@ def test_workflow_file_access_uses_node_execution_pointer(monkeypatch: pytest.Mo
|
||||
|
||||
assert result is response
|
||||
assert get_binding.call_args.kwargs["binding_id"] == "binding-workflow"
|
||||
client.read_workspace_file_sync.assert_called_once_with("binding-workflow-ref", "report.txt")
|
||||
client.read_binding_file_sync.assert_called_once_with("binding-workflow-ref", "report.txt")
|
||||
session.rollback.assert_called_once_with()
|
||||
|
||||
|
||||
def test_workflow_download_uses_authenticated_account_and_trusted_file_request(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
execution = SimpleNamespace(
|
||||
agent_workspace_binding_id="binding-workflow",
|
||||
process_data_dict={"workflow_agent_binding_id": "workflow-binding-1"},
|
||||
)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = execution
|
||||
binding = SimpleNamespace(
|
||||
agent_id="agent-1",
|
||||
backend_binding_ref="binding-workflow-ref",
|
||||
agent_config_version_id="config-1",
|
||||
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
|
||||
)
|
||||
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=binding))
|
||||
events: list[str] = []
|
||||
|
||||
@contextmanager
|
||||
def session_scope():
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
events.append("session-exit")
|
||||
|
||||
monkeypatch.setattr(sandbox_module.session_factory, "create_session", session_scope)
|
||||
client = MagicMock()
|
||||
client.download_binding_file_sync.side_effect = lambda _request: (
|
||||
events.append("client-download") or BindingFileDownloadResponse(reference="dify-file-ref:canonical")
|
||||
)
|
||||
request_download = MagicMock(
|
||||
side_effect=lambda **_kwargs: (
|
||||
events.append("file-request") or SimpleNamespace(download_uri="/files/tools/report.txt?timestamp=1&sign=2")
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.agent_app_sandbox_service.FileRequestService",
|
||||
lambda: SimpleNamespace(request_download=request_download),
|
||||
)
|
||||
monkeypatch.setattr("services.agent_app_sandbox_service.dify_config.FILES_URL", "https://files.example")
|
||||
|
||||
result = WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).download_file(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_run_id="run-1",
|
||||
node_id="node-1",
|
||||
node_execution_id="execution-1",
|
||||
account_id="account-1",
|
||||
path="report.txt",
|
||||
)
|
||||
|
||||
request = client.download_binding_file_sync.call_args.args[0]
|
||||
assert request.execution_context.user_id == "account-1"
|
||||
assert request.execution_context.user_from == "account"
|
||||
assert request.execution_context.node_execution_id == "execution-1"
|
||||
session.rollback.assert_called_once_with()
|
||||
assert events == ["session-exit", "client-download", "file-request"]
|
||||
request_download.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
user_id="account-1",
|
||||
user_from="account",
|
||||
invoke_from="debugger",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:canonical"},
|
||||
)
|
||||
assert result.url == "https://files.example/files/tools/report.txt?timestamp=1&sign=2&as_attachment=true"
|
||||
|
||||
|
||||
def test_agent_app_download_uses_complete_account_context_after_session_exit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
events: list[str] = []
|
||||
session = MagicMock()
|
||||
session.scalar.side_effect = [
|
||||
SimpleNamespace(app_id="app-1", backing_app_id=None),
|
||||
SimpleNamespace(agent_workspace_binding_id="binding-build"),
|
||||
]
|
||||
|
||||
@contextmanager
|
||||
def session_scope():
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
events.append("session-exit")
|
||||
|
||||
monkeypatch.setattr(sandbox_module.session_factory, "create_session", session_scope)
|
||||
binding = SimpleNamespace(
|
||||
agent_id="agent-1",
|
||||
backend_binding_ref="binding-build-ref",
|
||||
agent_config_version_id="config-1",
|
||||
agent_config_version_kind=AgentConfigVersionKind.DRAFT,
|
||||
)
|
||||
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=binding))
|
||||
client = MagicMock()
|
||||
client.download_binding_file_sync.side_effect = lambda _request: (
|
||||
events.append("client-download") or BindingFileDownloadResponse(reference="dify-file-ref:canonical")
|
||||
)
|
||||
request_download = MagicMock(
|
||||
side_effect=lambda **_kwargs: (
|
||||
events.append("file-request") or SimpleNamespace(download_uri="/files/tools/report.txt?timestamp=1&sign=2")
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sandbox_module,
|
||||
"FileRequestService",
|
||||
lambda: SimpleNamespace(request_download=request_download),
|
||||
)
|
||||
monkeypatch.setattr(sandbox_module.dify_config, "FILES_URL", "https://files.example")
|
||||
|
||||
result = AgentAppSandboxService(client_factory=lambda: nullcontext(client)).download_file(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
agent_id="agent-1",
|
||||
caller_type="build_draft",
|
||||
caller_id="build-1",
|
||||
account_id="account-1",
|
||||
path="report.txt",
|
||||
)
|
||||
|
||||
request = client.download_binding_file_sync.call_args.args[0]
|
||||
assert request.backend_binding_ref == "binding-build-ref"
|
||||
assert request.path == "report.txt"
|
||||
assert request.execution_context.model_dump(exclude_none=True) == {
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "account-1",
|
||||
"user_from": "account",
|
||||
"app_id": "app-1",
|
||||
"agent_id": "agent-1",
|
||||
"agent_config_version_id": "config-1",
|
||||
"agent_config_version_kind": "draft",
|
||||
"agent_mode": "agent_app",
|
||||
"invoke_from": "debugger",
|
||||
}
|
||||
assert events == ["session-exit", "client-download", "file-request"]
|
||||
request_download.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
user_id="account-1",
|
||||
user_from="account",
|
||||
invoke_from="debugger",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:canonical"},
|
||||
)
|
||||
assert result.url == "https://files.example/files/tools/report.txt?timestamp=1&sign=2&as_attachment=true"
|
||||
|
||||
|
||||
def test_file_request_rejection_maps_to_download_unavailable(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
request_download = MagicMock(side_effect=ValueError("reference is not accessible"))
|
||||
monkeypatch.setattr(
|
||||
sandbox_module,
|
||||
"FileRequestService",
|
||||
lambda: SimpleNamespace(request_download=request_download),
|
||||
)
|
||||
|
||||
with pytest.raises(AgentSandboxInspectorError) as exc_info:
|
||||
sandbox_module._download_response(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
reference="dify-file-ref:untrusted",
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "binding_file_download_unavailable"
|
||||
assert exc_info.value.status_code == 502
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"execution",
|
||||
[
|
||||
pytest.param(
|
||||
SimpleNamespace(
|
||||
agent_workspace_binding_id=None,
|
||||
process_data_dict={"workflow_agent_binding_id": "workflow-binding-1"},
|
||||
),
|
||||
id="missing-binding-pointer",
|
||||
),
|
||||
pytest.param(
|
||||
SimpleNamespace(
|
||||
agent_workspace_binding_id="binding-workflow",
|
||||
process_data_dict={},
|
||||
),
|
||||
id="missing-process-data",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_workflow_download_rejects_missing_binding_metadata_before_network(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
execution: SimpleNamespace,
|
||||
) -> None:
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = execution
|
||||
_use_session(monkeypatch, session)
|
||||
client = MagicMock()
|
||||
|
||||
with pytest.raises(AgentSandboxInspectorError) as exc_info:
|
||||
WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).download_file(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_run_id="run-1",
|
||||
node_id="node-1",
|
||||
node_execution_id="execution-1",
|
||||
account_id="account-1",
|
||||
path="report.txt",
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "no_active_binding"
|
||||
client.download_binding_file_sync.assert_not_called()
|
||||
|
||||
|
||||
def test_workflow_download_rejects_non_active_or_mismatched_binding_before_network(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
execution = SimpleNamespace(
|
||||
agent_workspace_binding_id="binding-workflow",
|
||||
process_data_dict={"workflow_agent_binding_id": "workflow-binding-1"},
|
||||
)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = execution
|
||||
_use_session(monkeypatch, session)
|
||||
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=None))
|
||||
client = MagicMock()
|
||||
|
||||
with pytest.raises(AgentSandboxInspectorError) as exc_info:
|
||||
WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).download_file(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_run_id="run-1",
|
||||
node_id="node-1",
|
||||
node_execution_id="execution-1",
|
||||
account_id="account-1",
|
||||
path="report.txt",
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "no_active_binding"
|
||||
client.download_binding_file_sync.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user