Files
dify/api/tests/unit_tests/services/test_app_dsl_service.py

914 lines
34 KiB
Python

import base64
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
import yaml
from dify_graph.enums import NodeType
from models import Account, AppMode
from models.model import IconType
from services import app_dsl_service
from services.app_dsl_service import (
AppDslService,
CheckDependenciesPendingData,
ImportMode,
ImportStatus,
PendingData,
_check_version_compatibility,
)
class _FakeHttpResponse:
def __init__(self, content: bytes, *, raises: Exception | None = None):
self.content = content
self._raises = raises
def raise_for_status(self) -> None:
if self._raises is not None:
raise self._raises
def _account_mock(*, tenant_id: str = "tenant-1", account_id: str = "account-1") -> MagicMock:
account = MagicMock(spec=Account)
account.current_tenant_id = tenant_id
account.id = account_id
return account
def _yaml_dump(data: dict) -> str:
return yaml.safe_dump(data, allow_unicode=True)
def _workflow_yaml(*, version: str = app_dsl_service.CURRENT_DSL_VERSION) -> str:
return _yaml_dump(
{
"version": version,
"kind": "app",
"app": {"name": "My App", "mode": AppMode.WORKFLOW.value},
"workflow": {"graph": {"nodes": []}, "features": {}},
}
)
def test_check_version_compatibility_invalid_version_returns_failed():
assert _check_version_compatibility("not-a-version") == ImportStatus.FAILED
def test_check_version_compatibility_newer_version_returns_pending():
assert _check_version_compatibility("99.0.0") == ImportStatus.PENDING
def test_check_version_compatibility_major_older_returns_pending(monkeypatch):
monkeypatch.setattr(app_dsl_service, "CURRENT_DSL_VERSION", "1.0.0")
assert _check_version_compatibility("0.9.9") == ImportStatus.PENDING
def test_check_version_compatibility_minor_older_returns_completed_with_warnings():
assert _check_version_compatibility("0.5.0") == ImportStatus.COMPLETED_WITH_WARNINGS
def test_check_version_compatibility_equal_returns_completed():
assert _check_version_compatibility(app_dsl_service.CURRENT_DSL_VERSION) == ImportStatus.COMPLETED
def test_import_app_invalid_import_mode_raises_value_error():
service = AppDslService(MagicMock())
with pytest.raises(ValueError, match="Invalid import_mode"):
service.import_app(account=_account_mock(), import_mode="invalid-mode", yaml_content="version: '0.1.0'")
def test_import_app_yaml_url_requires_url():
service = AppDslService(MagicMock())
result = service.import_app(account=_account_mock(), import_mode=ImportMode.YAML_URL, yaml_url=None)
assert result.status == ImportStatus.FAILED
assert "yaml_url is required" in result.error
def test_import_app_yaml_content_requires_content():
service = AppDslService(MagicMock())
result = service.import_app(account=_account_mock(), import_mode=ImportMode.YAML_CONTENT, yaml_content=None)
assert result.status == ImportStatus.FAILED
assert "yaml_content is required" in result.error
def test_import_app_yaml_url_fetch_error_returns_failed(monkeypatch):
def fake_get(_url: str, **_kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(app_dsl_service.ssrf_proxy, "get", fake_get)
service = AppDslService(MagicMock())
result = service.import_app(
account=_account_mock(), import_mode=ImportMode.YAML_URL, yaml_url="https://example.com/a.yml"
)
assert result.status == ImportStatus.FAILED
assert "Error fetching YAML from URL: boom" in result.error
def test_import_app_yaml_url_empty_content_returns_failed(monkeypatch):
def fake_get(_url: str, **_kwargs):
return _FakeHttpResponse(b"")
monkeypatch.setattr(app_dsl_service.ssrf_proxy, "get", fake_get)
service = AppDslService(MagicMock())
result = service.import_app(
account=_account_mock(), import_mode=ImportMode.YAML_URL, yaml_url="https://example.com/a.yml"
)
assert result.status == ImportStatus.FAILED
assert "Empty content" in result.error
def test_import_app_yaml_url_file_too_large_returns_failed(monkeypatch):
def fake_get(_url: str, **_kwargs):
return _FakeHttpResponse(b"x" * (app_dsl_service.DSL_MAX_SIZE + 1))
monkeypatch.setattr(app_dsl_service.ssrf_proxy, "get", fake_get)
service = AppDslService(MagicMock())
result = service.import_app(
account=_account_mock(), import_mode=ImportMode.YAML_URL, yaml_url="https://example.com/a.yml"
)
assert result.status == ImportStatus.FAILED
assert "File size exceeds" in result.error
def test_import_app_yaml_not_mapping_returns_failed():
service = AppDslService(MagicMock())
result = service.import_app(account=_account_mock(), import_mode=ImportMode.YAML_CONTENT, yaml_content="[]")
assert result.status == ImportStatus.FAILED
assert "content must be a mapping" in result.error
def test_import_app_version_not_str_returns_failed():
service = AppDslService(MagicMock())
yaml_content = _yaml_dump({"version": 1, "kind": "app", "app": {"name": "x", "mode": "workflow"}})
result = service.import_app(account=_account_mock(), import_mode=ImportMode.YAML_CONTENT, yaml_content=yaml_content)
assert result.status == ImportStatus.FAILED
assert "Invalid version type" in result.error
def test_import_app_missing_app_data_returns_failed():
service = AppDslService(MagicMock())
result = service.import_app(
account=_account_mock(),
import_mode=ImportMode.YAML_CONTENT,
yaml_content=_yaml_dump({"version": "0.6.0", "kind": "app"}),
)
assert result.status == ImportStatus.FAILED
assert "Missing app data" in result.error
def test_import_app_app_id_not_found_returns_failed(monkeypatch):
def fake_select(_model):
stmt = MagicMock()
stmt.where.return_value = stmt
return stmt
monkeypatch.setattr(app_dsl_service, "select", fake_select)
session = MagicMock()
session.scalar.return_value = None
service = AppDslService(session)
result = service.import_app(
account=_account_mock(),
import_mode=ImportMode.YAML_CONTENT,
yaml_content=_workflow_yaml(),
app_id="missing-app",
)
assert result.status == ImportStatus.FAILED
assert result.error == "App not found"
def test_import_app_overwrite_only_allows_workflow_and_advanced_chat(monkeypatch):
def fake_select(_model):
stmt = MagicMock()
stmt.where.return_value = stmt
return stmt
monkeypatch.setattr(app_dsl_service, "select", fake_select)
existing_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.CHAT.value)
session = MagicMock()
session.scalar.return_value = existing_app
service = AppDslService(session)
result = service.import_app(
account=_account_mock(),
import_mode=ImportMode.YAML_CONTENT,
yaml_content=_workflow_yaml(),
app_id="app-1",
)
assert result.status == ImportStatus.FAILED
assert "Only workflow or advanced chat apps" in result.error
def test_import_app_pending_stores_import_info_in_redis():
service = AppDslService(MagicMock())
result = service.import_app(
account=_account_mock(),
import_mode=ImportMode.YAML_CONTENT,
yaml_content=_workflow_yaml(version="99.0.0"),
name="n",
description="d",
icon_type="emoji",
icon="i",
icon_background="#000000",
)
assert result.status == ImportStatus.PENDING
assert result.imported_dsl_version == "99.0.0"
app_dsl_service.redis_client.setex.assert_called_once()
call = app_dsl_service.redis_client.setex.call_args
redis_key = call.args[0]
assert redis_key.startswith(app_dsl_service.IMPORT_INFO_REDIS_KEY_PREFIX)
def test_import_app_completed_uses_declared_dependencies(monkeypatch):
dependencies_payload = [{"id": "langgenius/google", "version": "1.0.0"}]
plugin_deps = [SimpleNamespace(model_dump=lambda: dependencies_payload[0])]
monkeypatch.setattr(
app_dsl_service.PluginDependency,
"model_validate",
lambda d: plugin_deps[0],
)
created_app = SimpleNamespace(id="app-new", mode=AppMode.WORKFLOW.value, tenant_id="tenant-1")
monkeypatch.setattr(AppDslService, "_create_or_update_app", lambda *_args, **_kwargs: created_app)
draft_var_service = MagicMock()
monkeypatch.setattr(app_dsl_service, "WorkflowDraftVariableService", lambda *args, **kwargs: draft_var_service)
service = AppDslService(MagicMock())
result = service.import_app(
account=_account_mock(),
import_mode=ImportMode.YAML_CONTENT,
yaml_content=_yaml_dump(
{
"version": app_dsl_service.CURRENT_DSL_VERSION,
"kind": "app",
"app": {"name": "My App", "mode": AppMode.WORKFLOW.value},
"workflow": {"graph": {"nodes": []}, "features": {}},
"dependencies": dependencies_payload,
}
),
)
assert result.status == ImportStatus.COMPLETED
assert result.app_id == "app-new"
draft_var_service.delete_workflow_variables.assert_called_once_with(app_id="app-new")
@pytest.mark.parametrize("has_workflow", [True, False])
def test_import_app_legacy_versions_extract_dependencies(monkeypatch, has_workflow: bool):
monkeypatch.setattr(
AppDslService,
"_extract_dependencies_from_workflow_graph",
lambda *_args, **_kwargs: ["from-workflow"],
)
monkeypatch.setattr(
AppDslService,
"_extract_dependencies_from_model_config",
lambda *_args, **_kwargs: ["from-model-config"],
)
monkeypatch.setattr(
app_dsl_service.DependenciesAnalysisService,
"generate_latest_dependencies",
lambda deps: [SimpleNamespace(model_dump=lambda: {"dep": deps[0]})],
)
created_app = SimpleNamespace(id="app-legacy", mode=AppMode.WORKFLOW.value, tenant_id="tenant-1")
monkeypatch.setattr(AppDslService, "_create_or_update_app", lambda *_args, **_kwargs: created_app)
draft_var_service = MagicMock()
monkeypatch.setattr(app_dsl_service, "WorkflowDraftVariableService", lambda *args, **kwargs: draft_var_service)
data: dict = {
"version": "0.1.5",
"kind": "app",
"app": {"name": "Legacy", "mode": AppMode.WORKFLOW.value},
}
if has_workflow:
data["workflow"] = {"graph": {"nodes": []}, "features": {}}
else:
data["model_config"] = {"model": {"provider": "openai"}}
service = AppDslService(MagicMock())
result = service.import_app(
account=_account_mock(), import_mode=ImportMode.YAML_CONTENT, yaml_content=_yaml_dump(data)
)
assert result.status == ImportStatus.COMPLETED_WITH_WARNINGS
draft_var_service.delete_workflow_variables.assert_called_once_with(app_id="app-legacy")
def test_import_app_yaml_error_returns_failed(monkeypatch):
def bad_safe_load(_content: str):
raise yaml.YAMLError("bad")
monkeypatch.setattr(app_dsl_service.yaml, "safe_load", bad_safe_load)
service = AppDslService(MagicMock())
result = service.import_app(account=_account_mock(), import_mode=ImportMode.YAML_CONTENT, yaml_content="x: y")
assert result.status == ImportStatus.FAILED
assert result.error.startswith("Invalid YAML format:")
def test_import_app_unexpected_error_returns_failed(monkeypatch):
monkeypatch.setattr(
AppDslService, "_create_or_update_app", lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("oops"))
)
service = AppDslService(MagicMock())
result = service.import_app(
account=_account_mock(), import_mode=ImportMode.YAML_CONTENT, yaml_content=_workflow_yaml()
)
assert result.status == ImportStatus.FAILED
assert result.error == "oops"
def test_confirm_import_expired_returns_failed():
service = AppDslService(MagicMock())
result = service.confirm_import(import_id="import-1", account=_account_mock())
assert result.status == ImportStatus.FAILED
assert "expired" in result.error
def test_confirm_import_invalid_pending_data_type_returns_failed():
app_dsl_service.redis_client.get.return_value = 123
service = AppDslService(MagicMock())
result = service.confirm_import(import_id="import-1", account=_account_mock())
assert result.status == ImportStatus.FAILED
assert "Invalid import information" in result.error
def test_confirm_import_success_deletes_redis_key(monkeypatch):
def fake_select(_model):
stmt = MagicMock()
stmt.where.return_value = stmt
return stmt
monkeypatch.setattr(app_dsl_service, "select", fake_select)
session = MagicMock()
session.scalar.return_value = None
service = AppDslService(session)
pending = PendingData(
import_mode=ImportMode.YAML_CONTENT,
yaml_content=_workflow_yaml(),
name="name",
description="desc",
icon_type="emoji",
icon="🤖",
icon_background="#fff",
app_id=None,
)
app_dsl_service.redis_client.get.return_value = pending.model_dump_json()
created_app = SimpleNamespace(id="confirmed-app", mode=AppMode.WORKFLOW.value, tenant_id="tenant-1")
monkeypatch.setattr(AppDslService, "_create_or_update_app", lambda *_args, **_kwargs: created_app)
result = service.confirm_import(import_id="import-1", account=_account_mock())
assert result.status == ImportStatus.COMPLETED
assert result.app_id == "confirmed-app"
app_dsl_service.redis_client.delete.assert_called_once()
def test_confirm_import_exception_returns_failed(monkeypatch):
app_dsl_service.redis_client.get.return_value = "not-json"
monkeypatch.setattr(
PendingData, "model_validate_json", lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("bad"))
)
service = AppDslService(MagicMock())
result = service.confirm_import(import_id="import-1", account=_account_mock())
assert result.status == ImportStatus.FAILED
assert result.error == "bad"
def test_check_dependencies_returns_empty_when_no_redis_data():
service = AppDslService(MagicMock())
result = service.check_dependencies(app_model=SimpleNamespace(id="app-1", tenant_id="tenant-1"))
assert result.leaked_dependencies == []
def test_check_dependencies_calls_analysis_service(monkeypatch):
pending = CheckDependenciesPendingData(dependencies=[], app_id="app-1").model_dump_json()
app_dsl_service.redis_client.get.return_value = pending
dep = app_dsl_service.PluginDependency.model_validate(
{"type": "package", "value": {"plugin_unique_identifier": "acme/foo", "version": "1.0.0"}}
)
monkeypatch.setattr(
app_dsl_service.DependenciesAnalysisService,
"get_leaked_dependencies",
lambda *, tenant_id, dependencies: [dep],
)
service = AppDslService(MagicMock())
result = service.check_dependencies(app_model=SimpleNamespace(id="app-1", tenant_id="tenant-1"))
assert len(result.leaked_dependencies) == 1
def test_create_or_update_app_missing_mode_raises():
service = AppDslService(MagicMock())
with pytest.raises(ValueError, match="loss app mode"):
service._create_or_update_app(app=None, data={"app": {}}, account=_account_mock())
def test_create_or_update_app_existing_app_updates_fields(monkeypatch):
fixed_now = object()
monkeypatch.setattr(app_dsl_service, "naive_utc_now", lambda: fixed_now)
workflow_service = MagicMock()
workflow_service.get_draft_workflow.return_value = None
monkeypatch.setattr(app_dsl_service, "WorkflowService", lambda: workflow_service)
monkeypatch.setattr(
app_dsl_service.variable_factory,
"build_environment_variable_from_mapping",
lambda _m: SimpleNamespace(kind="env"),
)
monkeypatch.setattr(
app_dsl_service.variable_factory,
"build_conversation_variable_from_mapping",
lambda _m: SimpleNamespace(kind="conv"),
)
app = SimpleNamespace(
id="app-1",
tenant_id="tenant-1",
mode=AppMode.WORKFLOW.value,
name="old",
description="old-desc",
icon_type=IconType.EMOJI,
icon="old-icon",
icon_background="#111111",
updated_by=None,
updated_at=None,
app_model_config=None,
)
service = AppDslService(MagicMock())
updated = service._create_or_update_app(
app=app,
data={
"app": {"mode": AppMode.WORKFLOW.value, "name": "yaml-name", "icon_type": IconType.IMAGE, "icon": "X"},
"workflow": {"graph": {"nodes": []}, "features": {}},
},
account=_account_mock(),
name="override-name",
description=None,
icon_background="#222222",
)
assert updated is app
assert app.name == "override-name"
assert app.icon_type == IconType.IMAGE
assert app.icon == "X"
assert app.icon_background == "#222222"
assert app.updated_at is fixed_now
def test_create_or_update_app_new_app_requires_tenant():
account = _account_mock()
account.current_tenant_id = None
service = AppDslService(MagicMock())
with pytest.raises(ValueError, match="Current tenant is not set"):
service._create_or_update_app(
app=None,
data={"app": {"mode": AppMode.WORKFLOW.value, "name": "n"}},
account=account,
)
def test_create_or_update_app_creates_workflow_app_and_saves_dependencies(monkeypatch):
class DummyApp(SimpleNamespace):
pass
monkeypatch.setattr(app_dsl_service, "App", DummyApp)
sent: list[tuple[str, object]] = []
monkeypatch.setattr(app_dsl_service.app_was_created, "send", lambda app, account: sent.append((app.id, account.id)))
workflow_service = MagicMock()
workflow_service.get_draft_workflow.return_value = SimpleNamespace(unique_hash="uh")
monkeypatch.setattr(app_dsl_service, "WorkflowService", lambda: workflow_service)
monkeypatch.setattr(
app_dsl_service.variable_factory,
"build_environment_variable_from_mapping",
lambda _m: SimpleNamespace(kind="env"),
)
monkeypatch.setattr(
app_dsl_service.variable_factory,
"build_conversation_variable_from_mapping",
lambda _m: SimpleNamespace(kind="conv"),
)
monkeypatch.setattr(
AppDslService, "decrypt_dataset_id", lambda *_args, **_kwargs: "00000000-0000-0000-0000-000000000000"
)
session = MagicMock()
service = AppDslService(session)
deps = [
app_dsl_service.PluginDependency.model_validate(
{"type": "package", "value": {"plugin_unique_identifier": "acme/foo", "version": "1.0.0"}}
)
]
data = {
"app": {"mode": AppMode.WORKFLOW.value, "name": "n"},
"workflow": {
"environment_variables": [{"x": 1}],
"conversation_variables": [{"y": 2}],
"graph": {
"nodes": [
{"data": {"type": NodeType.KNOWLEDGE_RETRIEVAL, "dataset_ids": ["enc-1", "enc-2"]}},
]
},
"features": {},
},
}
app = service._create_or_update_app(app=None, data=data, account=_account_mock(), dependencies=deps)
assert app.tenant_id == "tenant-1"
assert sent == [(app.id, "account-1")]
app_dsl_service.redis_client.setex.assert_called()
workflow_service.sync_draft_workflow.assert_called_once()
passed_graph = workflow_service.sync_draft_workflow.call_args.kwargs["graph"]
dataset_ids = passed_graph["nodes"][0]["data"]["dataset_ids"]
assert dataset_ids == ["00000000-0000-0000-0000-000000000000", "00000000-0000-0000-0000-000000000000"]
def test_create_or_update_app_workflow_missing_workflow_data_raises():
service = AppDslService(MagicMock())
with pytest.raises(ValueError, match="Missing workflow data"):
service._create_or_update_app(
app=SimpleNamespace(
id="a",
tenant_id="t",
mode=AppMode.WORKFLOW.value,
name="n",
description="d",
icon_background="#fff",
app_model_config=None,
),
data={"app": {"mode": AppMode.WORKFLOW.value}},
account=_account_mock(),
)
def test_create_or_update_app_chat_requires_model_config():
service = AppDslService(MagicMock())
with pytest.raises(ValueError, match="Missing model_config"):
service._create_or_update_app(
app=SimpleNamespace(
id="a",
tenant_id="t",
mode=AppMode.CHAT.value,
name="n",
description="d",
icon_background="#fff",
app_model_config=None,
),
data={"app": {"mode": AppMode.CHAT.value}},
account=_account_mock(),
)
def test_create_or_update_app_chat_creates_model_config_and_sends_event(monkeypatch):
class DummyModelConfig(SimpleNamespace):
def from_model_config_dict(self, _cfg: dict):
return self
monkeypatch.setattr(app_dsl_service, "AppModelConfig", DummyModelConfig)
sent: list[str] = []
monkeypatch.setattr(
app_dsl_service.app_model_config_was_updated, "send", lambda app, app_model_config: sent.append(app.id)
)
session = MagicMock()
service = AppDslService(session)
app = SimpleNamespace(
id="app-1",
tenant_id="tenant-1",
mode=AppMode.CHAT.value,
name="n",
description="d",
icon_background="#fff",
app_model_config=None,
)
service._create_or_update_app(
app=app,
data={"app": {"mode": AppMode.CHAT.value}, "model_config": {"model": {"provider": "openai"}}},
account=_account_mock(),
)
assert app.app_model_config_id is not None
assert sent == ["app-1"]
session.add.assert_called()
def test_create_or_update_app_invalid_mode_raises():
service = AppDslService(MagicMock())
with pytest.raises(ValueError, match="Invalid app mode"):
service._create_or_update_app(
app=SimpleNamespace(
id="a",
tenant_id="t",
mode=AppMode.RAG_PIPELINE.value,
name="n",
description="d",
icon_background="#fff",
app_model_config=None,
),
data={"app": {"mode": AppMode.RAG_PIPELINE.value}},
account=_account_mock(),
)
def test_export_dsl_delegates_by_mode(monkeypatch):
workflow_calls: list[bool] = []
model_calls: list[bool] = []
monkeypatch.setattr(AppDslService, "_append_workflow_export_data", lambda **_kwargs: workflow_calls.append(True))
monkeypatch.setattr(
AppDslService, "_append_model_config_export_data", lambda *_args, **_kwargs: model_calls.append(True)
)
workflow_app = SimpleNamespace(
mode=AppMode.WORKFLOW.value,
tenant_id="tenant-1",
name="n",
icon="i",
icon_type="emoji",
icon_background="#fff",
description="d",
use_icon_as_answer_icon=False,
app_model_config=None,
)
AppDslService.export_dsl(workflow_app)
assert workflow_calls == [True]
chat_app = SimpleNamespace(
mode=AppMode.CHAT.value,
tenant_id="tenant-1",
name="n",
icon="i",
icon_type="emoji",
icon_background="#fff",
description="d",
use_icon_as_answer_icon=False,
app_model_config=SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": []}}),
)
AppDslService.export_dsl(chat_app)
assert model_calls == [True]
def test_append_workflow_export_data_filters_and_overrides(monkeypatch):
workflow_dict = {
"graph": {
"nodes": [
{"data": {"type": NodeType.KNOWLEDGE_RETRIEVAL, "dataset_ids": ["d1", "d2"]}},
{"data": {"type": NodeType.TOOL, "credential_id": "secret"}},
{
"data": {
"type": NodeType.AGENT,
"agent_parameters": {"tools": {"value": [{"credential_id": "secret"}]}},
}
},
{"data": {"type": NodeType.TRIGGER_SCHEDULE.value, "config": {"x": 1}}},
{"data": {"type": NodeType.TRIGGER_WEBHOOK.value, "webhook_url": "x", "webhook_debug_url": "y"}},
{"data": {"type": NodeType.TRIGGER_PLUGIN.value, "subscription_id": "s"}},
]
}
}
workflow = SimpleNamespace(to_dict=lambda *, include_secret: workflow_dict)
workflow_service = MagicMock()
workflow_service.get_draft_workflow.return_value = workflow
monkeypatch.setattr(app_dsl_service, "WorkflowService", lambda: workflow_service)
monkeypatch.setattr(
AppDslService, "encrypt_dataset_id", lambda *, dataset_id, tenant_id: f"enc:{tenant_id}:{dataset_id}"
)
monkeypatch.setattr(
TriggerScheduleNode := app_dsl_service.TriggerScheduleNode,
"get_default_config",
lambda: {"config": {"default": True}},
)
monkeypatch.setattr(AppDslService, "_extract_dependencies_from_workflow", lambda *_args, **_kwargs: ["dep-1"])
monkeypatch.setattr(
app_dsl_service.DependenciesAnalysisService,
"generate_dependencies",
lambda *, tenant_id, dependencies: [
SimpleNamespace(model_dump=lambda: {"tenant": tenant_id, "dep": dependencies[0]})
],
)
monkeypatch.setattr(app_dsl_service, "jsonable_encoder", lambda x: x)
export_data: dict = {}
AppDslService._append_workflow_export_data(
export_data=export_data,
app_model=SimpleNamespace(tenant_id="tenant-1"),
include_secret=False,
workflow_id=None,
)
nodes = export_data["workflow"]["graph"]["nodes"]
assert nodes[0]["data"]["dataset_ids"] == ["enc:tenant-1:d1", "enc:tenant-1:d2"]
assert "credential_id" not in nodes[1]["data"]
assert "credential_id" not in nodes[2]["data"]["agent_parameters"]["tools"]["value"][0]
assert nodes[3]["data"]["config"] == {"default": True}
assert nodes[4]["data"]["webhook_url"] == ""
assert nodes[4]["data"]["webhook_debug_url"] == ""
assert nodes[5]["data"]["subscription_id"] == ""
assert export_data["dependencies"] == [{"tenant": "tenant-1", "dep": "dep-1"}]
def test_append_workflow_export_data_missing_workflow_raises(monkeypatch):
workflow_service = MagicMock()
workflow_service.get_draft_workflow.return_value = None
monkeypatch.setattr(app_dsl_service, "WorkflowService", lambda: workflow_service)
with pytest.raises(ValueError, match="Missing draft workflow configuration"):
AppDslService._append_workflow_export_data(
export_data={},
app_model=SimpleNamespace(tenant_id="tenant-1"),
include_secret=False,
workflow_id=None,
)
def test_append_model_config_export_data_filters_credential_id(monkeypatch):
monkeypatch.setattr(AppDslService, "_extract_dependencies_from_model_config", lambda *_args, **_kwargs: ["dep-1"])
monkeypatch.setattr(
app_dsl_service.DependenciesAnalysisService,
"generate_dependencies",
lambda *, tenant_id, dependencies: [
SimpleNamespace(model_dump=lambda: {"tenant": tenant_id, "dep": dependencies[0]})
],
)
monkeypatch.setattr(app_dsl_service, "jsonable_encoder", lambda x: x)
app_model_config = SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": [{"credential_id": "secret"}]}})
app_model = SimpleNamespace(tenant_id="tenant-1", app_model_config=app_model_config)
export_data: dict = {}
AppDslService._append_model_config_export_data(export_data, app_model)
assert export_data["model_config"]["agent_mode"]["tools"] == [{}]
assert export_data["dependencies"] == [{"tenant": "tenant-1", "dep": "dep-1"}]
def test_append_model_config_export_data_requires_app_config():
with pytest.raises(ValueError, match="Missing app configuration"):
AppDslService._append_model_config_export_data({}, SimpleNamespace(app_model_config=None))
def test_extract_dependencies_from_workflow_graph_covers_all_node_types(monkeypatch):
monkeypatch.setattr(
app_dsl_service.DependenciesAnalysisService,
"analyze_tool_dependency",
lambda provider_id: f"tool:{provider_id}",
)
monkeypatch.setattr(
app_dsl_service.DependenciesAnalysisService,
"analyze_model_provider_dependency",
lambda provider: f"model:{provider}",
)
monkeypatch.setattr(app_dsl_service.ToolNodeData, "model_validate", lambda _d: SimpleNamespace(provider_id="p1"))
monkeypatch.setattr(
app_dsl_service.LLMNodeData, "model_validate", lambda _d: SimpleNamespace(model=SimpleNamespace(provider="m1"))
)
monkeypatch.setattr(
app_dsl_service.QuestionClassifierNodeData,
"model_validate",
lambda _d: SimpleNamespace(model=SimpleNamespace(provider="m2")),
)
monkeypatch.setattr(
app_dsl_service.ParameterExtractorNodeData,
"model_validate",
lambda _d: SimpleNamespace(model=SimpleNamespace(provider="m3")),
)
def kr_validate(_d):
return SimpleNamespace(
retrieval_mode="multiple",
multiple_retrieval_config=SimpleNamespace(
reranking_mode="weighted_score",
weights=SimpleNamespace(vector_setting=SimpleNamespace(embedding_provider_name="m4")),
reranking_model=None,
),
single_retrieval_config=None,
)
monkeypatch.setattr(app_dsl_service.KnowledgeRetrievalNodeData, "model_validate", kr_validate)
graph = {
"nodes": [
{"data": {"type": NodeType.TOOL}},
{"data": {"type": NodeType.LLM}},
{"data": {"type": NodeType.QUESTION_CLASSIFIER}},
{"data": {"type": NodeType.PARAMETER_EXTRACTOR}},
{"data": {"type": NodeType.KNOWLEDGE_RETRIEVAL}},
{"data": {"type": "unknown"}},
]
}
deps = AppDslService._extract_dependencies_from_workflow_graph(graph)
assert deps == ["tool:p1", "model:m1", "model:m2", "model:m3", "model:m4"]
def test_extract_dependencies_from_workflow_graph_handles_exceptions(monkeypatch):
monkeypatch.setattr(
app_dsl_service.ToolNodeData, "model_validate", lambda _d: (_ for _ in ()).throw(ValueError("bad"))
)
deps = AppDslService._extract_dependencies_from_workflow_graph({"nodes": [{"data": {"type": NodeType.TOOL}}]})
assert deps == []
def test_extract_dependencies_from_model_config_parses_providers(monkeypatch):
monkeypatch.setattr(
app_dsl_service.DependenciesAnalysisService,
"analyze_model_provider_dependency",
lambda provider: f"model:{provider}",
)
monkeypatch.setattr(
app_dsl_service.DependenciesAnalysisService,
"analyze_tool_dependency",
lambda provider_id: f"tool:{provider_id}",
)
deps = AppDslService._extract_dependencies_from_model_config(
{
"model": {"provider": "p1"},
"dataset_configs": {
"datasets": {"datasets": [{"reranking_model": {"reranking_provider_name": {"provider": "p2"}}}]}
},
"agent_mode": {"tools": [{"provider_id": "t1"}]},
}
)
assert deps == ["model:p1", "model:p2", "tool:t1"]
def test_extract_dependencies_from_model_config_handles_exceptions(monkeypatch):
monkeypatch.setattr(
app_dsl_service.DependenciesAnalysisService,
"analyze_model_provider_dependency",
lambda _p: (_ for _ in ()).throw(ValueError("bad")),
)
deps = AppDslService._extract_dependencies_from_model_config({"model": {"provider": "p1"}})
assert deps == []
def test_get_leaked_dependencies_empty_returns_empty():
assert AppDslService.get_leaked_dependencies("tenant-1", []) == []
def test_get_leaked_dependencies_delegates(monkeypatch):
monkeypatch.setattr(
app_dsl_service.DependenciesAnalysisService,
"get_leaked_dependencies",
lambda *, tenant_id, dependencies: [SimpleNamespace(tenant_id=tenant_id, deps=dependencies)],
)
res = AppDslService.get_leaked_dependencies("tenant-1", [SimpleNamespace(id="x")])
assert len(res) == 1
def test_encrypt_decrypt_dataset_id_respects_config(monkeypatch):
tenant_id = "tenant-1"
dataset_uuid = "00000000-0000-0000-0000-000000000000"
monkeypatch.setattr(app_dsl_service.dify_config, "DSL_EXPORT_ENCRYPT_DATASET_ID", False)
assert AppDslService.encrypt_dataset_id(dataset_id=dataset_uuid, tenant_id=tenant_id) == dataset_uuid
monkeypatch.setattr(app_dsl_service.dify_config, "DSL_EXPORT_ENCRYPT_DATASET_ID", True)
encrypted = AppDslService.encrypt_dataset_id(dataset_id=dataset_uuid, tenant_id=tenant_id)
assert encrypted != dataset_uuid
assert base64.b64decode(encrypted.encode())
assert AppDslService.decrypt_dataset_id(encrypted_data=encrypted, tenant_id=tenant_id) == dataset_uuid
def test_decrypt_dataset_id_returns_plain_uuid_unchanged():
value = "00000000-0000-0000-0000-000000000000"
assert AppDslService.decrypt_dataset_id(encrypted_data=value, tenant_id="tenant-1") == value
def test_decrypt_dataset_id_returns_none_on_invalid_data(monkeypatch):
monkeypatch.setattr(app_dsl_service.dify_config, "DSL_EXPORT_ENCRYPT_DATASET_ID", True)
assert AppDslService.decrypt_dataset_id(encrypted_data="not-base64", tenant_id="tenant-1") is None
def test_decrypt_dataset_id_returns_none_when_decrypted_is_not_uuid(monkeypatch):
monkeypatch.setattr(app_dsl_service.dify_config, "DSL_EXPORT_ENCRYPT_DATASET_ID", True)
encrypted = AppDslService.encrypt_dataset_id(dataset_id="not-a-uuid", tenant_id="tenant-1")
assert AppDslService.decrypt_dataset_id(encrypted_data=encrypted, tenant_id="tenant-1") is None
def test_is_valid_uuid_handles_bad_inputs():
assert AppDslService._is_valid_uuid("00000000-0000-0000-0000-000000000000") is True
assert AppDslService._is_valid_uuid("nope") is False