Files
dify/api/tests/unit_tests/services/app/test_console_gateway.py
T

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"])