mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 23:06:40 +08:00
980 lines
40 KiB
Python
980 lines
40 KiB
Python
"""Policy and existing DSL adapters retain their domain-specific behavior."""
|
|
|
|
import json
|
|
from collections.abc import Callable, Generator, Iterator
|
|
from typing import cast
|
|
from unittest.mock import Mock
|
|
from uuid import uuid4
|
|
from zipfile import ZipFile
|
|
|
|
import pytest
|
|
import yaml
|
|
from blinker import Namespace
|
|
from sqlalchemy import Engine, event, select
|
|
from sqlalchemy.exc import InvalidRequestError
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from core.plugin.entities.plugin import PluginDependency, PluginInstallation
|
|
from core.plugin.impl.plugin import PluginInstaller
|
|
from enums import CloudPlan, DeploymentEdition
|
|
from extensions.application_services.app import AppServices
|
|
from machinery.context import AppRequestContext, RequestContext
|
|
from models.account import Account, TenantAccountJoin, TenantAccountRole
|
|
from models.agent import (
|
|
Agent,
|
|
AgentConfigRevision,
|
|
AgentConfigRevisionOperation,
|
|
AgentConfigSnapshot,
|
|
AgentScope,
|
|
AgentSource,
|
|
AgentStatus,
|
|
WorkflowAgentBindingType,
|
|
WorkflowAgentNodeBinding,
|
|
)
|
|
from models.agent_config_entities import AgentSoulConfig
|
|
from models.model import App, AppMode, AppModelConfig, IconType
|
|
from models.workflow import Workflow, WorkflowType
|
|
from repositories.app.export_repository import AppExportRepository
|
|
from services.agent.dsl_entities import AGENT_PACKAGE_REF_KEY
|
|
from services.agent.package_resource_exporter import AgentPackageResourceExporter
|
|
from services.agent.roster_package_exporter import RosterAgentPackageExporter
|
|
from services.agent.roster_package_importer import RosterAgentPackageImporter
|
|
from services.agent.roster_service import AgentRosterService
|
|
from services.app import console_gateway
|
|
from services.app.console_gateway import AppLifecycleGateway, AppTransferGateway, EnterpriseConsoleAppAccess
|
|
from services.app.console_service import ConsoleAppNotFoundError, InvalidAppAccessModesError
|
|
from services.app.export_service import AppExportService
|
|
from services.app_dsl_service import AppDslService
|
|
from services.app_package_service import AppPackageService, PreparedAppPackage
|
|
from services.enterprise.enterprise_service import EnterpriseService, WebAppSettings
|
|
from services.entities.app_entities import (
|
|
AppExportOptions,
|
|
CopyAppParams,
|
|
)
|
|
from services.entities.dsl_entities import (
|
|
AppDslExportData,
|
|
AppImportPackage,
|
|
AppImportParams,
|
|
DraftGuardedImportParams,
|
|
Import,
|
|
ImportStatus,
|
|
)
|
|
from services.plugin.dependencies_analysis import DependenciesAnalysisService
|
|
from services.recommended_app_package_service import RecommendedAppPackageService
|
|
from services.system_feature_service import SystemFeatureService
|
|
from services.workflow_service import WorkflowService
|
|
from tests.helpers.unit.audio_runtime_fixtures import record_calls
|
|
from tests.helpers.unit.model_factories import make_account, make_tenant, make_upload_file
|
|
from tests.helpers.unit.rbac_fakes import RBACDomain
|
|
|
|
|
|
@pytest.mark.parametrize(("mode", "include_inline"), [(AppMode.WORKFLOW, False), (AppMode.ADVANCED_CHAT, True)])
|
|
def test_copy_preserves_shared_roster_bindings_and_clones_owned_inline_agents(
|
|
app_services: AppServices,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
copy_source: tuple[RequestContext, str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
mode: AppMode,
|
|
include_inline: bool,
|
|
) -> None:
|
|
context, app_id = copy_source
|
|
tenant_id, actor_id = context.active_workspace_id, context.account_id
|
|
workflow_id, roster_id, roster_snapshot_id, inline_id, inline_snapshot_id = [str(uuid4()) for _ in range(5)]
|
|
node_ids = ["roster-1", "roster-2"] + (["inline"] if include_inline else [])
|
|
graph = {
|
|
"nodes": [
|
|
{
|
|
"id": node_id,
|
|
"data": {"type": "agent", "title": node_id, "version": "2", "agent_node_kind": "dify_agent"},
|
|
}
|
|
for node_id in node_ids
|
|
],
|
|
"edges": list[dict[str, str]](),
|
|
}
|
|
with sqlite_session_factory.begin() as session:
|
|
source_app = session.get(App, app_id)
|
|
assert source_app is not None
|
|
source_app.mode = mode
|
|
source_workflow = Workflow(
|
|
id=workflow_id,
|
|
tenant_id=tenant_id,
|
|
app_id=app_id,
|
|
type=WorkflowType.from_app_mode(mode),
|
|
version=Workflow.VERSION_DRAFT,
|
|
graph=json.dumps(graph),
|
|
_features=json.dumps({}),
|
|
_environment_variables=[],
|
|
_conversation_variables=[],
|
|
created_by=actor_id,
|
|
)
|
|
roster = Agent(
|
|
id=roster_id,
|
|
tenant_id=tenant_id,
|
|
name="Shared roster",
|
|
scope=AgentScope.ROSTER,
|
|
source=AgentSource.AGENT_APP,
|
|
status=AgentStatus.ACTIVE,
|
|
active_config_snapshot_id=roster_snapshot_id,
|
|
active_config_is_published=True,
|
|
)
|
|
session.add_all(
|
|
[
|
|
source_workflow,
|
|
roster,
|
|
AgentConfigSnapshot(
|
|
id=roster_snapshot_id,
|
|
tenant_id=tenant_id,
|
|
agent_id=roster_id,
|
|
version=1,
|
|
config_snapshot=AgentSoulConfig(config_note="Roster configuration"),
|
|
created_by=actor_id,
|
|
),
|
|
AgentConfigRevision(
|
|
tenant_id=tenant_id,
|
|
agent_id=roster_id,
|
|
current_snapshot_id=roster_snapshot_id,
|
|
revision=1,
|
|
operation=AgentConfigRevisionOperation.PUBLISH_DRAFT,
|
|
created_by=actor_id,
|
|
),
|
|
]
|
|
)
|
|
if include_inline:
|
|
session.add_all(
|
|
[
|
|
Agent(
|
|
id=inline_id,
|
|
tenant_id=tenant_id,
|
|
name="Owned inline",
|
|
scope=AgentScope.WORKFLOW_ONLY,
|
|
source=AgentSource.WORKFLOW,
|
|
status=AgentStatus.ACTIVE,
|
|
app_id=app_id,
|
|
workflow_id=workflow_id,
|
|
workflow_node_id="inline",
|
|
active_config_snapshot_id=inline_snapshot_id,
|
|
),
|
|
AgentConfigSnapshot(
|
|
id=inline_snapshot_id,
|
|
tenant_id=tenant_id,
|
|
agent_id=inline_id,
|
|
version=1,
|
|
config_snapshot=AgentSoulConfig(config_note="Inline configuration"),
|
|
created_by=actor_id,
|
|
),
|
|
]
|
|
)
|
|
for node_id in node_ids:
|
|
is_inline = node_id == "inline"
|
|
session.add(
|
|
WorkflowAgentNodeBinding(
|
|
tenant_id=tenant_id,
|
|
app_id=app_id,
|
|
workflow_id=workflow_id,
|
|
workflow_version=Workflow.VERSION_DRAFT,
|
|
node_id=node_id,
|
|
binding_type=(
|
|
WorkflowAgentBindingType.INLINE_AGENT if is_inline else WorkflowAgentBindingType.ROSTER_AGENT
|
|
),
|
|
agent_id=inline_id if is_inline else roster_id,
|
|
current_snapshot_id=inline_snapshot_id if is_inline else roster_snapshot_id,
|
|
node_job_config={
|
|
"workflow_prompt": f"Task for {node_id}",
|
|
"mode": "let_agent_figure_it_out",
|
|
"output_routes": {"enabled": False, "routes": [{"id": "route-1", "name": "Continue"}]},
|
|
},
|
|
created_by=actor_id,
|
|
)
|
|
)
|
|
monkeypatch.setattr(DependenciesAnalysisService, "generate_dependencies", lambda **_kwargs: [])
|
|
monkeypatch.setattr(SystemFeatureService, "is_webapp_auth_enabled", lambda: False)
|
|
workflow_service = WorkflowService(session_maker=sqlite_session_factory)
|
|
monkeypatch.setattr(
|
|
"services.app_dsl_service.WorkflowService", Mock(wraps=WorkflowService, return_value=workflow_service)
|
|
)
|
|
|
|
exported = app_services.console.export(context, app_id, AppExportOptions(format="yaml"))
|
|
assert isinstance(exported, str)
|
|
portable = yaml.safe_load(exported)
|
|
assert all(
|
|
AGENT_PACKAGE_REF_KEY in node["data"]["agent_binding"] for node in portable["workflow"]["graph"]["nodes"]
|
|
)
|
|
|
|
result, copied = app_services.console.copy(context, app_id, CopyAppParams(name="Copy"))
|
|
assert result.status == ImportStatus.COMPLETED
|
|
assert copied is not None
|
|
assert copied.id != app_id
|
|
with sqlite_session_factory() as session:
|
|
copied_workflow = session.scalar(select(Workflow).where(Workflow.app_id == copied.id))
|
|
assert copied_workflow is not None
|
|
assert copied_workflow.version == Workflow.VERSION_DRAFT
|
|
bindings = {
|
|
binding.node_id: binding
|
|
for binding in session.scalars(
|
|
select(WorkflowAgentNodeBinding).where(WorkflowAgentNodeBinding.workflow_id == copied_workflow.id)
|
|
)
|
|
}
|
|
assert set(bindings) == set(node_ids)
|
|
source_jobs = {
|
|
binding.node_id: binding.node_job_config_dict
|
|
for binding in session.scalars(
|
|
select(WorkflowAgentNodeBinding).where(WorkflowAgentNodeBinding.workflow_id == workflow_id)
|
|
)
|
|
}
|
|
for node_id in ("roster-1", "roster-2"):
|
|
assert bindings[node_id].binding_type == WorkflowAgentBindingType.ROSTER_AGENT
|
|
assert bindings[node_id].agent_id == roster_id
|
|
assert bindings[node_id].current_snapshot_id == roster_snapshot_id
|
|
for node_id in node_ids:
|
|
assert bindings[node_id].node_job_config_dict == source_jobs[node_id]
|
|
if include_inline:
|
|
binding = bindings["inline"]
|
|
assert binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT
|
|
assert binding.agent_id != inline_id
|
|
assert binding.current_snapshot_id != inline_snapshot_id
|
|
cloned_agent = session.get(Agent, binding.agent_id)
|
|
assert cloned_agent is not None
|
|
assert cloned_agent.scope == AgentScope.WORKFLOW_ONLY
|
|
assert cloned_agent.app_id == copied.id
|
|
assert cloned_agent.workflow_id == copied_workflow.id
|
|
cloned_snapshot = session.get(AgentConfigSnapshot, binding.current_snapshot_id)
|
|
assert cloned_snapshot is not None
|
|
assert cloned_snapshot.config_snapshot_dict["config_note"] == "Inline configuration"
|
|
assert len(list(session.scalars(select(Agent).where(Agent.scope == AgentScope.ROSTER)))) == 1
|
|
assert session.scalar(select(Workflow.graph).where(Workflow.id == workflow_id)) == source_workflow.graph
|
|
expected_app_ids = set(session.scalars(select(App.id)))
|
|
expected_workflow_ids = set(session.scalars(select(Workflow.id)))
|
|
source_agent = session.get(Agent, roster_id)
|
|
assert source_agent is not None
|
|
source_agent.tenant_id = str(uuid4())
|
|
session.commit()
|
|
|
|
failed, failed_copy = app_services.console.copy(context, app_id, CopyAppParams(name="Invalid copy"))
|
|
assert failed.status == ImportStatus.FAILED
|
|
assert failed_copy is None
|
|
with sqlite_session_factory() as session:
|
|
assert set(session.scalars(select(App.id))) == expected_app_ids
|
|
assert set(session.scalars(select(Workflow.id))) == expected_workflow_ids
|
|
|
|
|
|
@pytest.fixture
|
|
def copy_source(sqlite_session_factory: sessionmaker[Session]) -> tuple[RequestContext, str]:
|
|
actor, workspace, app_id = str(uuid4()), str(uuid4()), str(uuid4())
|
|
account = make_account(account_id=actor, name="Creator", email=f"{actor}@example.com")
|
|
tenant = make_tenant(tenant_id=workspace, name="Workspace")
|
|
with sqlite_session_factory.begin() as session:
|
|
app = App(id=app_id, tenant_id=workspace, name="Original", mode=AppMode.CHAT, enable_site=True, enable_api=True)
|
|
config = AppModelConfig(app_id=app_id, model="{}")
|
|
session.add_all(
|
|
[
|
|
account,
|
|
tenant,
|
|
TenantAccountJoin(tenant_id=workspace, account_id=actor, role=TenantAccountRole.OWNER),
|
|
app,
|
|
config,
|
|
]
|
|
)
|
|
session.flush()
|
|
app.app_model_config_id = config.id
|
|
return RequestContext("request", None, actor, workspace), app_id
|
|
|
|
|
|
@pytest.fixture
|
|
def copy_connections(sqlite_engine: Engine) -> Iterator[set[object]]:
|
|
connections: set[object] = set()
|
|
|
|
def checkout(connection: object, *_args: object) -> None:
|
|
connections.add(connection)
|
|
|
|
def checkin(connection: object, *_args: object) -> None:
|
|
connections.remove(connection)
|
|
|
|
event.listen(sqlite_engine, "checkout", checkout)
|
|
event.listen(sqlite_engine, "checkin", checkin)
|
|
try:
|
|
yield connections
|
|
assert not connections
|
|
finally:
|
|
event.remove(sqlite_engine, "checkout", checkout)
|
|
event.remove(sqlite_engine, "checkin", checkin)
|
|
|
|
|
|
@pytest.mark.parametrize("status", list(ImportStatus))
|
|
@pytest.mark.parametrize("missing_settings", [False, True])
|
|
def test_copy_finishes_transactions_before_external_access(
|
|
app_services: AppServices,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
sqlite_engine: Engine,
|
|
copy_source: tuple[RequestContext, str],
|
|
copy_connections: set[object],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
status: ImportStatus,
|
|
missing_settings: bool,
|
|
) -> None:
|
|
context, source_id = copy_source
|
|
copied_id = str(uuid4())
|
|
calls: list[str] = []
|
|
commits: list[Session] = []
|
|
|
|
def committed(session: Session) -> None:
|
|
if session.get_bind() is sqlite_engine:
|
|
commits.append(session)
|
|
|
|
def dependencies(*, tenant_id: str, dependencies: list[str]) -> list[PluginDependency]:
|
|
assert tenant_id == context.active_workspace_id
|
|
assert dependencies == []
|
|
assert not copy_connections, "Export must close its read transaction before resolving plugin metadata"
|
|
assert commits == [], "A read-only export must not commit"
|
|
calls.append("plugins")
|
|
return []
|
|
|
|
def persist(dsl: AppDslService, *, account: Account, yaml_content: str, name: str, **_kwargs: object) -> Import:
|
|
assert not copy_connections
|
|
assert account.id == context.account_id
|
|
assert account.current_tenant_id == context.active_workspace_id
|
|
assert yaml.safe_load(yaml_content)["app"]["name"] == "Original"
|
|
assert name == "Copy"
|
|
dsl._session.add(
|
|
App(
|
|
id=copied_id,
|
|
tenant_id=context.active_workspace_id,
|
|
name=name,
|
|
mode=AppMode.CHAT,
|
|
enable_site=True,
|
|
enable_api=True,
|
|
)
|
|
)
|
|
dsl._session.flush()
|
|
return Import(id="import-1", status=status, app_id=copied_id)
|
|
|
|
def get_access(app_id: str) -> WebAppSettings:
|
|
assert not copy_connections
|
|
assert app_id == source_id
|
|
calls.append("read_access")
|
|
if missing_settings:
|
|
raise ValueError("No settings")
|
|
return WebAppSettings(accessMode="private")
|
|
|
|
def set_access(app_id: str, access_mode: str) -> None:
|
|
assert not copy_connections
|
|
assert app_id == copied_id
|
|
assert access_mode == ("public" if missing_settings else "private")
|
|
with sqlite_session_factory() as session:
|
|
assert session.get(App, copied_id) is not None
|
|
calls.append("write_access")
|
|
|
|
def permissions(_self: EnterpriseConsoleAppAccess, caller: RequestContext, app_id: str) -> list[str]:
|
|
assert not copy_connections
|
|
assert caller == context
|
|
assert app_id == copied_id
|
|
calls.append("permissions")
|
|
return ["app.edit"]
|
|
|
|
monkeypatch.setattr(DependenciesAnalysisService, "generate_dependencies", dependencies)
|
|
monkeypatch.setattr(AppDslService, "import_app", persist)
|
|
monkeypatch.setattr(SystemFeatureService, "is_webapp_auth_enabled", lambda: True)
|
|
monkeypatch.setattr(EnterpriseService.WebAppAuth, "get_app_access_mode_by_id", get_access)
|
|
monkeypatch.setattr(EnterpriseService.WebAppAuth, "update_app_access_mode", set_access)
|
|
monkeypatch.setattr(EnterpriseConsoleAppAccess, "created_permissions", permissions)
|
|
event.listen(Session, "after_commit", committed)
|
|
try:
|
|
result, copied = app_services.console.copy(context, source_id, CopyAppParams(name="Copy"))
|
|
finally:
|
|
event.remove(Session, "after_commit", committed)
|
|
|
|
completed = status in {ImportStatus.COMPLETED, ImportStatus.COMPLETED_WITH_WARNINGS}
|
|
assert result.status == status
|
|
assert not copy_connections
|
|
assert len(commits) == int(completed)
|
|
with sqlite_session_factory() as session:
|
|
assert (session.get(App, copied_id) is not None) is completed
|
|
assert calls == (["plugins", "read_access", "write_access", "permissions"] if completed else ["plugins"])
|
|
if completed:
|
|
assert copied is not None
|
|
assert copied.id == copied_id
|
|
assert copied.permission_keys == ["app.edit"]
|
|
else:
|
|
assert copied is None
|
|
|
|
|
|
def test_copy_rejects_foreign_import_result_before_external_effects(
|
|
app_services: AppServices,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
copy_source: tuple[RequestContext, str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
context, source_id = copy_source
|
|
foreign_id = str(uuid4())
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add(
|
|
App(
|
|
id=foreign_id,
|
|
tenant_id=str(uuid4()),
|
|
name="Foreign",
|
|
mode=AppMode.CHAT,
|
|
enable_site=True,
|
|
enable_api=True,
|
|
)
|
|
)
|
|
|
|
def unexpected_effect(*_args: object, **_kwargs: object) -> None:
|
|
pytest.fail("A foreign import result must not receive access or permission updates")
|
|
|
|
monkeypatch.setattr(DependenciesAnalysisService, "generate_dependencies", lambda **_kwargs: [])
|
|
monkeypatch.setattr(
|
|
AppDslService,
|
|
"import_app",
|
|
lambda *_args, **_kwargs: Import(id="import-1", status=ImportStatus.COMPLETED, app_id=foreign_id),
|
|
)
|
|
monkeypatch.setattr(EnterpriseConsoleAppAccess, "inherit_access", unexpected_effect)
|
|
monkeypatch.setattr(EnterpriseConsoleAppAccess, "created_permissions", unexpected_effect)
|
|
with pytest.raises(ConsoleAppNotFoundError):
|
|
app_services.console.copy(context, source_id, CopyAppParams())
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("edition", "plan", "allowed"),
|
|
[
|
|
(DeploymentEdition.CLOUD, CloudPlan.SANDBOX, False),
|
|
(DeploymentEdition.CLOUD, CloudPlan.PROFESSIONAL, True),
|
|
(DeploymentEdition.CLOUD, CloudPlan.TEAM, True),
|
|
(DeploymentEdition.COMMUNITY, CloudPlan.SANDBOX, True),
|
|
(DeploymentEdition.ENTERPRISE, CloudPlan.SANDBOX, True),
|
|
],
|
|
)
|
|
def test_only_cloud_version_export_consults_workspace_plan(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
config_overrides: Callable[..., None],
|
|
rbac_domain: RBACDomain,
|
|
edition: DeploymentEdition,
|
|
plan: CloudPlan,
|
|
allowed: bool,
|
|
) -> None:
|
|
config_overrides(DEPLOYMENT_EDITION=edition)
|
|
calls = []
|
|
|
|
def get_plan(workspace_id: str) -> CloudPlan:
|
|
calls.append(workspace_id)
|
|
return plan
|
|
|
|
monkeypatch.setattr("services.app.console_gateway.FeatureService.get_workspace_plan", get_plan)
|
|
gateway = EnterpriseConsoleAppAccess(members=rbac_domain.rbac.members)
|
|
assert gateway.can_export_version("workspace") is allowed
|
|
assert calls == (["workspace"] if edition == DeploymentEdition.CLOUD else [])
|
|
|
|
|
|
def test_access_mode_batch_validates_completeness(monkeypatch: pytest.MonkeyPatch, rbac_domain: RBACDomain) -> None:
|
|
monkeypatch.setattr("services.app.console_gateway.SystemFeatureService.is_webapp_auth_enabled", lambda: True)
|
|
monkeypatch.setattr(
|
|
"services.app.console_gateway.EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id",
|
|
lambda **_kwargs: {},
|
|
)
|
|
gateway = EnterpriseConsoleAppAccess(members=rbac_domain.rbac.members)
|
|
with pytest.raises(InvalidAppAccessModesError):
|
|
gateway.access_modes(["app"])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"mode", [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT, AppMode.CHAT, AppMode.COMPLETION, AppMode.AGENT_CHAT]
|
|
)
|
|
def test_dsl_export_uses_owned_short_session_and_preserves_selectors(
|
|
sqlite_engine: Engine,
|
|
mode: AppMode,
|
|
) -> None:
|
|
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False, close_resets_only=False)
|
|
app_id, workspace = str(uuid4()), str(uuid4())
|
|
with factory.begin() as session:
|
|
session.add(App(id=app_id, tenant_id=workspace, name="Example", mode=mode, enable_site=True, enable_api=True))
|
|
calls = []
|
|
opened = []
|
|
|
|
class Dsl:
|
|
def __init__(self, session: Session) -> None:
|
|
opened.append(session)
|
|
|
|
def load_export_data(self, **kwargs: object) -> AppDslExportData:
|
|
calls.append(kwargs)
|
|
assert kwargs["session"] is opened[-1]
|
|
return AppDslExportData(workspace, {"kind": "app", "app": {"mode": mode}}, [])
|
|
|
|
@staticmethod
|
|
def serialize_export_data(prepared: AppDslExportData) -> str:
|
|
assert prepared.tenant_id == workspace
|
|
with pytest.raises(InvalidRequestError):
|
|
opened[-1].execute(select(App))
|
|
return f"kind: app\napp: {{mode: {mode}}}"
|
|
|
|
gateway = AppTransferGateway(
|
|
session_factory=factory,
|
|
dsl_factory=cast(Callable[[Session], AppDslService], Dsl),
|
|
packages=AppPackageService(),
|
|
agent_packages=RosterAgentPackageExporter(),
|
|
agent_importer=RosterAgentPackageImporter(),
|
|
recommended_packages=RecommendedAppPackageService(sources=Mock(), exporter=Mock()),
|
|
)
|
|
context = RequestContext("request", None, "actor", workspace)
|
|
options = AppExportOptions(include_secret=True, workflow_id="workflow")
|
|
dsl = gateway.export_dsl(context, app_id, options)
|
|
assert dsl == f"kind: app\napp: {{mode: {mode}}}"
|
|
exported_app = calls[0]["app_model"]
|
|
assert isinstance(exported_app, App)
|
|
assert exported_app.id == app_id
|
|
assert calls[0]["include_secret"] is True
|
|
assert calls[0]["workflow_id"] == "workflow"
|
|
with pytest.raises(InvalidRequestError):
|
|
opened[0].execute(select(App))
|
|
with pytest.raises(ConsoleAppNotFoundError):
|
|
gateway.export_dsl(context._replace(active_workspace_id=str(uuid4())), app_id, options)
|
|
assert len(calls) == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"mode",
|
|
[AppMode.CHAT, AppMode.COMPLETION, AppMode.AGENT_CHAT, AppMode.WORKFLOW, AppMode.ADVANCED_CHAT, AppMode.AGENT],
|
|
)
|
|
@pytest.mark.parametrize("remote_fails", [False, True])
|
|
@pytest.mark.parametrize("surface", ["console", "openapi", "inner"])
|
|
def test_real_dsl_export_releases_connection_before_plugin_request(
|
|
sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch, mode: AppMode, remote_fails: bool, surface: str
|
|
) -> None:
|
|
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False, close_resets_only=False)
|
|
app_id, workspace, actor = str(uuid4()), str(uuid4()), str(uuid4())
|
|
with factory.begin() as session:
|
|
app = App(id=app_id, tenant_id=workspace, name="Example", mode=mode, enable_site=True, enable_api=True)
|
|
session.add(app)
|
|
session.flush()
|
|
if mode in {AppMode.WORKFLOW, AppMode.ADVANCED_CHAT}:
|
|
session.add(
|
|
Workflow(
|
|
tenant_id=workspace,
|
|
app_id=app_id,
|
|
type=WorkflowType.WORKFLOW,
|
|
version=Workflow.VERSION_DRAFT,
|
|
graph='{"nodes":[],"edges":[]}',
|
|
_features="{}",
|
|
created_by=actor,
|
|
)
|
|
)
|
|
elif mode == AppMode.AGENT:
|
|
AgentRosterService(session).create_backing_agent_for_app(
|
|
tenant_id=workspace, account_id=actor, app_id=app_id, name="Example"
|
|
)
|
|
else:
|
|
config = AppModelConfig(app_id=app_id, model='{"provider":"langgenius/openai/openai","name":"example"}')
|
|
session.add(config)
|
|
session.flush()
|
|
app.app_model_config_id = config.id
|
|
|
|
checked_out: set[object] = set()
|
|
calls: list[list[str]] = []
|
|
|
|
def checkout(connection: object, *_args: object) -> None:
|
|
checked_out.add(connection)
|
|
|
|
def checkin(connection: object, *_args: object) -> None:
|
|
checked_out.remove(connection)
|
|
|
|
def fetch_plugins(_self: PluginInstaller, tenant_id: str, plugin_ids: list[str]) -> list[PluginInstallation]:
|
|
assert tenant_id == workspace
|
|
assert not checked_out, "Plugin daemon I/O must run after returning the database connection"
|
|
calls.append(plugin_ids)
|
|
if remote_fails:
|
|
raise RuntimeError("Plugin daemon unavailable")
|
|
return []
|
|
|
|
monkeypatch.setattr(PluginInstaller, "fetch_plugin_installation_by_ids", fetch_plugins)
|
|
gateway = AppTransferGateway(
|
|
session_factory=factory,
|
|
dsl_factory=AppDslService,
|
|
packages=AppPackageService(),
|
|
agent_packages=RosterAgentPackageExporter(),
|
|
agent_importer=RosterAgentPackageImporter(),
|
|
recommended_packages=RecommendedAppPackageService(sources=Mock(), exporter=Mock()),
|
|
)
|
|
exports = AppExportService(
|
|
apps=AppExportRepository(session_factory=factory), serialize=AppDslService.serialize_export_data
|
|
)
|
|
|
|
def export() -> str:
|
|
if surface == "console":
|
|
return gateway.export_dsl(RequestContext("request", None, actor, workspace), app_id, AppExportOptions())
|
|
if surface == "openapi":
|
|
return exports.export_app(
|
|
AppRequestContext(tenant_id=workspace, app_id=app_id),
|
|
AppExportOptions(),
|
|
)
|
|
return exports.export_for_inner(app_id, AppExportOptions())
|
|
|
|
event.listen(sqlite_engine, "checkout", checkout)
|
|
event.listen(sqlite_engine, "checkin", checkin)
|
|
try:
|
|
if remote_fails:
|
|
with pytest.raises(RuntimeError, match="Plugin daemon unavailable"):
|
|
export()
|
|
else:
|
|
exported = yaml.safe_load(export())
|
|
assert exported["app"]["mode"] == mode
|
|
assert exported["dependencies"] == []
|
|
assert len(calls) == 1
|
|
assert not checked_out
|
|
finally:
|
|
event.remove(sqlite_engine, "checkout", checkout)
|
|
event.remove(sqlite_engine, "checkin", checkin)
|
|
|
|
|
|
def test_console_package_export_preserves_icons_without_holding_database_connections(
|
|
app_services: AppServices,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
copy_source: tuple[RequestContext, str],
|
|
copy_connections: set[object],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
context, app_id = copy_source
|
|
payload = b"packaged icon"
|
|
upload = make_upload_file(
|
|
tenant_id=context.active_workspace_id,
|
|
created_by=context.account_id,
|
|
key="uploads/icon.png",
|
|
name="icon.png",
|
|
extension="png",
|
|
mime_type="image/png",
|
|
size=len(payload),
|
|
)
|
|
with sqlite_session_factory.begin() as session:
|
|
app = session.get(App, app_id)
|
|
assert app is not None
|
|
app.icon_type = IconType.IMAGE
|
|
app.icon = upload.id
|
|
session.add(upload)
|
|
|
|
calls: list[str] = []
|
|
|
|
class Storage:
|
|
def load_stream(self, filename: str) -> Generator[bytes]:
|
|
assert not copy_connections, "Archive storage I/O must follow closure of both App read sessions"
|
|
assert filename == upload.key
|
|
calls.append("storage")
|
|
yield payload
|
|
|
|
def dependencies(*, tenant_id: str, dependencies: list[str]) -> list[PluginDependency]:
|
|
assert not copy_connections
|
|
assert tenant_id == context.active_workspace_id
|
|
assert dependencies == []
|
|
calls.append("plugins")
|
|
return []
|
|
|
|
monkeypatch.setattr(
|
|
"services.app_package_service.AgentPackageResourceExporter",
|
|
lambda: AgentPackageResourceExporter(storage_backend=Storage()),
|
|
)
|
|
monkeypatch.setattr(DependenciesAnalysisService, "generate_dependencies", dependencies)
|
|
exported = app_services.console.export(context, app_id, AppExportOptions())
|
|
assert not isinstance(exported, str)
|
|
with exported:
|
|
with ZipFile(exported.archive) as archive:
|
|
manifest = yaml.safe_load(archive.read("manifest.yaml"))
|
|
data = yaml.safe_load(archive.read("app.yaml"))
|
|
icon = manifest["icons"][0]
|
|
assert data["app"]["icon"] == icon["id"]
|
|
assert archive.read(icon["path"]) == payload
|
|
assert calls == ["plugins", "storage"]
|
|
with sqlite_session_factory() as session:
|
|
source = session.get(App, app_id)
|
|
assert source is not None
|
|
assert source.icon == upload.id
|
|
|
|
|
|
@pytest.mark.parametrize("status", list(ImportStatus))
|
|
def test_console_package_import_forwards_archive_and_closes_it(
|
|
app_services: AppServices,
|
|
copy_source: tuple[RequestContext, str],
|
|
copy_connections: set[object],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
config_overrides: Callable[..., None],
|
|
status: ImportStatus,
|
|
) -> None:
|
|
config_overrides(RBAC_ENABLED=False, DEPLOYMENT_EDITION="COMMUNITY")
|
|
context, _ = copy_source
|
|
archives: list[PreparedAppPackage] = []
|
|
dsl = "kind: app\napp: {mode: chat}\n"
|
|
|
|
def import_app(
|
|
_self: AppDslService,
|
|
*,
|
|
account: Account,
|
|
yaml_content: str,
|
|
package: AppImportPackage | None,
|
|
**_kwargs: object,
|
|
) -> Import:
|
|
assert not copy_connections
|
|
assert account.id == context.account_id
|
|
assert isinstance(package, PreparedAppPackage)
|
|
assert package.dsl == yaml_content == dsl
|
|
assert not package.archive.closed
|
|
archives.append(package)
|
|
return Import(id="import-1", status=status)
|
|
|
|
monkeypatch.setattr(AppDslService, "import_app", import_app)
|
|
with AppPackageService().export(dsl=dsl, name="Example", workflow_tools=[]) as exported:
|
|
result = app_services.console.import_app(context, AppImportParams(mode="yaml-content"), source=exported.archive)
|
|
assert result.status == status
|
|
assert len(archives) == 1
|
|
assert archives[0].archive.closed
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("params", "draft_hash"),
|
|
[
|
|
(DraftGuardedImportParams(mode="yaml-content", yaml_content="kind: app\n", draft_hash="exported"), "exported"),
|
|
(DraftGuardedImportParams(mode="yaml-content", yaml_content="kind: app\n"), None),
|
|
(AppImportParams(mode="yaml-content", yaml_content="kind: app\n"), None),
|
|
],
|
|
ids=["guarded", "guarded-without-hash", "console"],
|
|
)
|
|
def test_dsl_import_forwards_only_a_guarded_draft_hash(
|
|
app_services: AppServices,
|
|
copy_source: tuple[RequestContext, str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
config_overrides: Callable[..., None],
|
|
params: AppImportParams,
|
|
draft_hash: str | None,
|
|
) -> None:
|
|
config_overrides(RBAC_ENABLED=False, DEPLOYMENT_EDITION="COMMUNITY")
|
|
context, _ = copy_source
|
|
forwarded: list[str | None] = []
|
|
|
|
def import_app(_dsl: AppDslService, *, draft_hash: str | None, **_kwargs: object) -> Import:
|
|
forwarded.append(draft_hash)
|
|
return Import(id="import-1", status=ImportStatus.FAILED)
|
|
|
|
monkeypatch.setattr(AppDslService, "import_app", import_app)
|
|
|
|
app_services.console.import_app(context, params)
|
|
|
|
assert forwarded == [draft_hash]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("enter", "expected"),
|
|
[
|
|
(
|
|
lambda services, context, _app_id, params: services.imports.import_app(
|
|
context, params, preserve_agent_bindings=True
|
|
),
|
|
[True],
|
|
),
|
|
(lambda services, context, _app_id, params: services.console.import_app(context, params), [False]),
|
|
(
|
|
lambda services, context, app_id, _params: services.console.copy(
|
|
context, app_id, CopyAppParams(name="Copy")
|
|
),
|
|
[True],
|
|
),
|
|
],
|
|
ids=["imports-on", "console", "copy"],
|
|
)
|
|
def test_import_forwards_preserve_agent_bindings_per_entry(
|
|
app_services: AppServices,
|
|
copy_source: tuple[RequestContext, str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
config_overrides: Callable[..., None],
|
|
enter: Callable[[AppServices, RequestContext, str, AppImportParams], object],
|
|
expected: list[bool],
|
|
) -> None:
|
|
monkeypatch.setattr(DependenciesAnalysisService, "generate_dependencies", lambda **_kwargs: [])
|
|
config_overrides(RBAC_ENABLED=False, DEPLOYMENT_EDITION="COMMUNITY")
|
|
context, app_id = copy_source
|
|
forwarded: list[bool] = []
|
|
|
|
def import_app(_dsl: AppDslService, *, preserve_agent_bindings: bool, **_kwargs: object) -> Import:
|
|
forwarded.append(preserve_agent_bindings)
|
|
return Import(id="import-1", status=ImportStatus.FAILED)
|
|
|
|
monkeypatch.setattr(AppDslService, "import_app", import_app)
|
|
params = AppImportParams(mode="yaml-content", yaml_content="kind: app\n")
|
|
|
|
enter(app_services, context, app_id, params)
|
|
|
|
assert forwarded == expected
|
|
|
|
|
|
@pytest.mark.parametrize("mode", [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
|
def test_create_draft_saves_the_starter_graph_of_the_apps_mode(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
copy_source: tuple[RequestContext, str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
mode: AppMode,
|
|
) -> None:
|
|
context, app_id = copy_source
|
|
with sqlite_session_factory.begin() as session:
|
|
app = session.get(App, app_id)
|
|
assert app is not None
|
|
app.mode = mode
|
|
syncs: list[dict[str, object]] = []
|
|
monkeypatch.setattr(
|
|
WorkflowService, "sync_draft_workflow", record_calls(WorkflowService.sync_draft_workflow, syncs)
|
|
)
|
|
# The app event handlers reach the Flask-bound database; this test only checks the saved draft.
|
|
monkeypatch.setattr("services.workflow_service.app_draft_workflow_was_synced", Namespace().signal("synced"))
|
|
|
|
AppLifecycleGateway(session_factory=sqlite_session_factory).create_draft(context, app_id)
|
|
|
|
assert [sync["graph"] for sync in syncs] == [console_gateway._STARTER_DRAFT_GRAPHS[mode]]
|
|
assert syncs[0]["graph"] is not console_gateway._STARTER_DRAFT_GRAPHS[mode]
|
|
with sqlite_session_factory() as session:
|
|
draft = session.scalar(
|
|
select(Workflow).where(Workflow.app_id == app_id, Workflow.version == Workflow.VERSION_DRAFT)
|
|
)
|
|
assert draft is not None
|
|
assert draft.graph_dict == console_gateway._STARTER_DRAFT_GRAPHS[mode]
|
|
|
|
|
|
def test_chatflow_starter_draft_is_start_llm_answer_as_the_editor_saves_it() -> None:
|
|
graph = console_gateway._STARTER_DRAFT_GRAPHS[AppMode.ADVANCED_CHAT]
|
|
|
|
assert [(n["id"], n["data"]["type"], n["data"]["title"], n["position"]) for n in graph["nodes"]] == [
|
|
("start", "start", "Start", {"x": 80, "y": 282}),
|
|
("llm", "llm", "LLM", {"x": 380, "y": 282}),
|
|
("answer", "answer", "Answer", {"x": 680, "y": 282}),
|
|
]
|
|
assert all(
|
|
(n["type"], n["targetPosition"], n["sourcePosition"]) == ("custom", "left", "right") for n in graph["nodes"]
|
|
)
|
|
assert [(e["id"], e["source"], e["sourceHandle"], e["target"], e["targetHandle"]) for e in graph["edges"]] == [
|
|
("start-llm", "start", "source", "llm", "target"),
|
|
("llm-answer", "llm", "source", "answer", "target"),
|
|
]
|
|
assert graph["nodes"][2]["data"]["answer"] == "{{#llm.text#}}"
|
|
assert json.loads(json.dumps(graph))["nodes"][1]["data"]["model"]["mode"] == "chat"
|
|
|
|
|
|
def test_workflow_starter_draft_is_empty() -> None:
|
|
assert console_gateway._STARTER_DRAFT_GRAPHS[AppMode.WORKFLOW] == {"nodes": [], "edges": []}
|
|
|
|
|
|
@pytest.mark.parametrize("commit_fails", [False, True])
|
|
@pytest.mark.parametrize("entry", ["import", "confirm", "copy"])
|
|
@pytest.mark.parametrize("status", list(ImportStatus))
|
|
@pytest.mark.parametrize("created", [False, True])
|
|
def test_import_creator_access_runs_after_successful_commit(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
copy_source: tuple[RequestContext, str],
|
|
copy_connections: set[object],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
entry: str,
|
|
status: ImportStatus,
|
|
created: bool,
|
|
commit_fails: bool,
|
|
) -> None:
|
|
context, app_id = copy_source
|
|
result = Import(id="import", status=status, app_id=app_id, app_mode=AppMode.WORKFLOW, app_created=created)
|
|
events: list[str] = []
|
|
|
|
class Dsl:
|
|
def __init__(self, session: Session) -> None:
|
|
if commit_fails:
|
|
|
|
def fail_commit(_: Session) -> None:
|
|
raise RuntimeError("commit failed")
|
|
|
|
event.listen(session, "before_commit", fail_commit)
|
|
event.listen(session, "after_commit", lambda _: events.append("commit"))
|
|
event.listen(session, "after_rollback", lambda _: events.append("rollback"))
|
|
|
|
def import_app(self, **_kwargs: object) -> Import:
|
|
return result
|
|
|
|
def confirm_import(self, **_kwargs: object) -> Import:
|
|
return result
|
|
|
|
def sync(tenant_id: str, account_id: str, resource_type: str, resource_id: str) -> None:
|
|
assert not copy_connections
|
|
assert events == ["commit"]
|
|
assert (tenant_id, account_id, resource_type, resource_id) == (
|
|
context.active_workspace_id,
|
|
context.account_id,
|
|
"app",
|
|
app_id,
|
|
)
|
|
events.append("sync")
|
|
|
|
monkeypatch.setattr("services.enterprise.rbac_service.try_sync_creator_access_policy_member_bindings", sync)
|
|
gateway = AppTransferGateway(
|
|
session_factory=sqlite_session_factory,
|
|
dsl_factory=cast(Callable[[Session], AppDslService], Dsl),
|
|
packages=AppPackageService(),
|
|
agent_packages=RosterAgentPackageExporter(),
|
|
agent_importer=RosterAgentPackageImporter(),
|
|
recommended_packages=RecommendedAppPackageService(sources=Mock(), exporter=Mock()),
|
|
)
|
|
import_entry = gateway.import_copy if entry == "copy" else gateway.import_dsl
|
|
if commit_fails and status != ImportStatus.FAILED and not (entry == "copy" and status == ImportStatus.PENDING):
|
|
if entry == "confirm":
|
|
with pytest.raises(RuntimeError, match="commit failed"):
|
|
gateway.confirm_import(context, "import")
|
|
else:
|
|
with pytest.raises(RuntimeError, match="commit failed"):
|
|
import_entry(context, AppImportParams(mode="yaml-content"))
|
|
assert "sync" not in events
|
|
return
|
|
if entry == "confirm":
|
|
returned, was_created = gateway.confirm_import(context, "import")
|
|
assert was_created is created
|
|
else:
|
|
returned = import_entry(context, AppImportParams(mode="yaml-content"))
|
|
assert returned is result
|
|
expected = created and status in (ImportStatus.COMPLETED, ImportStatus.COMPLETED_WITH_WARNINGS)
|
|
assert (events.count("sync") == 1) is expected
|
|
|
|
|
|
def test_console_package_export_forwards_roster_options(
|
|
app_services: AppServices,
|
|
copy_source: tuple[RequestContext, str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
context, app_id = copy_source
|
|
captured: dict[str, object] = {}
|
|
|
|
def export_app(_self: AppPackageService, **kwargs: object) -> object:
|
|
captured.update(kwargs)
|
|
return object()
|
|
|
|
monkeypatch.setattr(AppPackageService, "export_app", export_app)
|
|
app_services.console.export(context, app_id, AppExportOptions(format="ifpkg", preserve_agent_bindings=True))
|
|
assert captured["preserve_agent_bindings"] is True
|
|
|
|
|
|
@pytest.mark.parametrize("outcome", ["disabled", "write_error", "false_result"])
|
|
def test_inherit_access_preserves_copy_contract(
|
|
monkeypatch: pytest.MonkeyPatch, rbac_domain: RBACDomain, outcome: str
|
|
) -> None:
|
|
from services.enterprise.enterprise_service import EnterpriseRequest
|
|
from services.errors.enterprise import EnterpriseServiceError
|
|
|
|
monkeypatch.setattr(SystemFeatureService, "is_webapp_auth_enabled", lambda: outcome != "disabled")
|
|
calls: list[str] = []
|
|
error = EnterpriseServiceError("write failed")
|
|
|
|
def request(method: str, endpoint: str, **kwargs: object) -> dict[str, object]:
|
|
calls.append(method)
|
|
if method == "GET":
|
|
assert endpoint == "/webapp/access-mode/id"
|
|
assert kwargs["params"] == {"appId": "source"}
|
|
return {"accessMode": "private_all"}
|
|
assert method == "POST"
|
|
assert endpoint == "/webapp/access-mode"
|
|
assert kwargs["json"] == {"appId": "target", "accessMode": "private_all"}
|
|
if outcome == "write_error":
|
|
raise error
|
|
return {"result": False}
|
|
|
|
monkeypatch.setattr(EnterpriseRequest, "send_request", request)
|
|
gateway = EnterpriseConsoleAppAccess(members=rbac_domain.rbac.members)
|
|
if outcome == "write_error":
|
|
with pytest.raises(EnterpriseServiceError) as raised:
|
|
gateway.inherit_access("source", "target")
|
|
assert raised.value is error
|
|
else:
|
|
assert gateway.inherit_access("source", "target") is None
|
|
assert calls == ([] if outcome == "disabled" else ["GET", "POST"])
|