mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 14:51:39 +08:00
1641 lines
67 KiB
Python
1641 lines
67 KiB
Python
"""Console use cases exercise ports without a request or an ORM session."""
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
from collections.abc import Callable, Sequence
|
|
from contextlib import AbstractContextManager
|
|
from dataclasses import dataclass, field, replace
|
|
from datetime import datetime
|
|
from io import BytesIO
|
|
from typing import BinaryIO, Literal, cast, override
|
|
from uuid import UUID, uuid4
|
|
from zipfile import ZipFile
|
|
|
|
import pytest
|
|
import yaml
|
|
from blinker import Signal
|
|
from sqlalchemy import Engine, event, func, select
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from core.plugin.entities.plugin import PluginDependency
|
|
from core.plugin.impl.plugin import PluginInstaller
|
|
from core.rbac import RBACPermission
|
|
from core.tools.entities.tool_entities import ApiProviderSchemaType, ToolProviderType
|
|
from core.tools.tool_manager import ToolManager
|
|
from events.app_event import app_was_updated
|
|
from extensions.application_services.app import AppServices
|
|
from extensions.ext_database import db
|
|
from extensions.ext_redis import redis_client
|
|
from graphon.model_runtime.entities.model_entities import ModelType
|
|
from libs.datetime_utils import naive_utc_now
|
|
from machinery.context import RequestContext
|
|
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
|
from models.agent import Agent, AgentConfigSnapshot, AgentStatus
|
|
from models.agent_config_entities import AgentSoulConfig
|
|
from models.model import App, AppMode, AppModelConfig, IconType, InstalledApp, Site
|
|
from models.provider import TenantDefaultModel
|
|
from models.provider_ids import GenericProviderID
|
|
from models.tools import ApiToolProvider, ToolLabelBinding, WorkflowToolProvider
|
|
from models.workflow import Workflow, WorkflowType
|
|
from repositories.app.console_repository import ConsoleAppRepository
|
|
from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository
|
|
from services.agent.errors import (
|
|
AgentAccessNotReadyError,
|
|
InvalidRosterAgentPackageError,
|
|
RosterAgentPackageTooLargeError,
|
|
)
|
|
from services.agent.roster_package_entities import RosterAgentPackageExport
|
|
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.console_gateway import AppLifecycleGateway, AppTransferGateway
|
|
from services.app.console_service import (
|
|
AppExportPaidPlanRequiredError,
|
|
ConsoleAppNotFoundError,
|
|
ConsoleApps,
|
|
ConsoleAppService,
|
|
CreatorsPlatformDisabledError,
|
|
InvalidAppExportError,
|
|
)
|
|
from services.app_dsl_service import AppDslService
|
|
from services.app_package_service import AppPackageService
|
|
from services.app_tracing_config_service import AppTraceSettings
|
|
from services.entities.app_entities import (
|
|
AppCreationSettings,
|
|
AppDeletion,
|
|
AppExportOptions,
|
|
AppListParams,
|
|
AppPage,
|
|
AppRecord,
|
|
AppReference,
|
|
CopyAppParams,
|
|
CreateAppParams,
|
|
ImportedAppPackage,
|
|
RecentAppListItem,
|
|
StarredAppListParams,
|
|
UpdateAppParams,
|
|
)
|
|
from services.entities.dsl_entities import (
|
|
AppImportPackage,
|
|
AppImportParams,
|
|
CheckDependenciesResult,
|
|
Import,
|
|
ImportStatus,
|
|
PackagedWorkflowTool,
|
|
)
|
|
from services.entities.feature_entities import FeatureModel, LimitationModel
|
|
from services.errors.base import NoPermissionError
|
|
from services.feature_service import FeatureService
|
|
from services.model_provider.service import ModelProviderService
|
|
from services.plugin.dependencies_analysis import DependenciesAnalysisService
|
|
from services.recommended_app_package_service import RecommendedAppPackageService
|
|
from services.workflow_tool_references import WorkflowToolTarget
|
|
from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task
|
|
from tests.helpers.unit.model_factories import make_account, make_tenant
|
|
from tests.helpers.unit.rbac_fakes import RBACDomain
|
|
|
|
CONTEXT = RequestContext("request", "trace", "actor", "workspace")
|
|
RECORD = AppRecord(id="app", name="Example", mode_compatible_with_agent="chat")
|
|
|
|
|
|
@dataclass
|
|
class Apps:
|
|
source: AppReference = AppReference("app", "Example", "chat", None)
|
|
records: list[AppRecord] = field(default_factory=lambda: [RECORD])
|
|
calls: list[tuple[object, ...]] = field(default_factory=list)
|
|
|
|
def list_apps(self, context: RequestContext, params: AppListParams | StarredAppListParams) -> AppPage:
|
|
self.calls.append(("list", context, params))
|
|
return AppPage(params.page, params.limit, len(self.records), False, self.records)
|
|
|
|
def recent(self, context: RequestContext, params: AppListParams) -> list[RecentAppListItem]:
|
|
self.calls.append(("recent", context, params))
|
|
return [RecentAppListItem("app", "Example", None, None, None, AppMode.CHAT, None, datetime(2026, 1, 1), None)]
|
|
|
|
def get(self, context: RequestContext, app_id: str) -> AppRecord:
|
|
self.calls.append(("get", context, app_id))
|
|
return RECORD
|
|
|
|
def create(self, context: RequestContext, params: CreateAppParams, settings: AppCreationSettings) -> AppRecord:
|
|
assert settings.app == {"mode": params.mode}
|
|
self.calls.append(("create", context, params))
|
|
return RECORD
|
|
|
|
def get_reference(self, context: RequestContext, app_id: str) -> AppReference:
|
|
self.calls.append(("get_reference", context, app_id))
|
|
return self.source
|
|
|
|
def get_trace(self, context: RequestContext, app_id: str) -> AppTraceSettings:
|
|
self.calls.append(("get_trace", context, app_id))
|
|
raise ValueError("Stored tracing JSON is malformed")
|
|
|
|
def set_trace(self, context: RequestContext, app_id: str, settings: AppTraceSettings) -> None:
|
|
self.calls.append(("set_trace", context, app_id, settings))
|
|
|
|
|
|
@dataclass
|
|
class Permissions:
|
|
def filter_list(self, params: AppListParams) -> AppListParams:
|
|
return params.model_copy(update={"accessible_app_ids": ["app"]})
|
|
|
|
def keys_for(self, app_ids: list[str]) -> dict[str, list[str]]:
|
|
return {app_id: ["app.preview"] for app_id in app_ids}
|
|
|
|
|
|
@dataclass
|
|
class Access:
|
|
paid: bool = True
|
|
calls: list[tuple[object, ...]] = field(default_factory=list)
|
|
|
|
def require_import(self, context: RequestContext, kind: Literal["url", "dsl", "agent"]) -> None:
|
|
del context, kind
|
|
pytest.fail("Unexpected import admission")
|
|
|
|
def imported_permissions(self, context: RequestContext, app_id: str) -> list[str]:
|
|
del context, app_id
|
|
pytest.fail("Unexpected import permissions lookup")
|
|
|
|
def initialize_import_access(self, app_id: str) -> None:
|
|
del app_id
|
|
pytest.fail("Unexpected import access initialization")
|
|
|
|
def permissions(self, context: RequestContext, *, app_id: str | None = None) -> Permissions:
|
|
self.calls.append(("permissions", context, app_id))
|
|
return Permissions()
|
|
|
|
def created_permissions(self, context: RequestContext, app_id: str) -> list[str]:
|
|
self.calls.append(("created_permissions", context, app_id))
|
|
return ["app.edit"]
|
|
|
|
def initialize_created_app(self, context: RequestContext, app_id: str) -> None:
|
|
self.calls.append(("initialize", context, app_id))
|
|
|
|
def inherit_access(self, source_app_id: str, app_id: str) -> None:
|
|
self.calls.append(("inherit", source_app_id, app_id))
|
|
|
|
def access_modes(self, app_ids: list[str]) -> dict[str, str]:
|
|
self.calls.append(("modes", app_ids))
|
|
return dict.fromkeys(app_ids, "private")
|
|
|
|
def access_mode(self, app_id: str) -> str | None:
|
|
del app_id
|
|
return "private"
|
|
|
|
def can_export_version(self, workspace_id: str) -> bool:
|
|
self.calls.append(("paid", workspace_id))
|
|
return self.paid
|
|
|
|
|
|
@dataclass
|
|
class Transfers:
|
|
status: ImportStatus = ImportStatus.COMPLETED
|
|
copied_id: str | None = RECORD.id
|
|
calls: list[tuple[object, ...]] = field(default_factory=list)
|
|
|
|
def download_import(self, url: str) -> AbstractContextManager[BinaryIO]:
|
|
del url
|
|
pytest.fail("Unexpected import download")
|
|
|
|
def read_import_yaml(self, source: BinaryIO) -> str | None:
|
|
del source
|
|
pytest.fail("Unexpected import content detection")
|
|
|
|
def read_app_package(self, source: BinaryIO) -> AppImportPackage | None:
|
|
del source
|
|
pytest.fail("Unexpected package read")
|
|
|
|
def confirm_import(self, context: RequestContext, import_id: str) -> tuple[Import, bool]:
|
|
del context, import_id
|
|
pytest.fail("Unexpected import confirmation")
|
|
|
|
def import_agent_package(self, context: RequestContext, source: BinaryIO) -> ImportedAppPackage:
|
|
del context, source
|
|
pytest.fail("Unexpected Agent package import")
|
|
|
|
def import_agent_template(self, context: RequestContext, params: AppImportParams) -> ImportedAppPackage:
|
|
del context, params
|
|
pytest.fail("Unexpected Agent template import")
|
|
|
|
def import_agent_package_url(self, context: RequestContext, params: AppImportParams) -> ImportedAppPackage:
|
|
del context, params
|
|
pytest.fail("Unexpected Agent package URL import")
|
|
|
|
def check_dependencies(self, context: RequestContext, app_id: str) -> CheckDependenciesResult:
|
|
del context, app_id
|
|
pytest.fail("Unexpected dependency check")
|
|
|
|
def plan_workflow_tools(
|
|
self, context: RequestContext, tools: Sequence[PackagedWorkflowTool], *, creates_root_app: bool
|
|
) -> dict[str, WorkflowToolTarget]:
|
|
del context, tools, creates_root_app
|
|
pytest.fail("Unexpected workflow tool planning")
|
|
|
|
def create_workflow_tool(
|
|
self, context: RequestContext, *, app_id: str, tool: PackagedWorkflowTool, target: WorkflowToolTarget
|
|
) -> None:
|
|
del context, app_id, tool, target
|
|
pytest.fail("Unexpected workflow tool creation")
|
|
|
|
def import_dsl(
|
|
self,
|
|
context: RequestContext,
|
|
params: AppImportParams,
|
|
*,
|
|
package: AppImportPackage | None = None,
|
|
preserve_agent_bindings: bool = False,
|
|
) -> Import:
|
|
assert package is None
|
|
self.calls.append(("import", context, params, preserve_agent_bindings))
|
|
return Import(id="import", status=self.status, app_id=self.copied_id)
|
|
|
|
def import_copy(self, context: RequestContext, params: AppImportParams) -> Import:
|
|
self.calls.append(("copy", context, params))
|
|
return Import(id="import", status=self.status, app_id=self.copied_id)
|
|
|
|
def export_dsl(self, context: RequestContext, app_id: str, options: AppExportOptions) -> str:
|
|
self.calls.append(("dsl", context, app_id, options))
|
|
return "app: example"
|
|
|
|
def export_app_package(
|
|
self, context: RequestContext, app_id: str, options: AppExportOptions
|
|
) -> RosterAgentPackageExport:
|
|
self.calls.append(("package", context, app_id, options))
|
|
return RosterAgentPackageExport(archive=BytesIO(b"package"), filename="example.ifpkg", size=7)
|
|
|
|
def export_agent_package(
|
|
self, *, workspace_id: str, agent_id: str, version_id: UUID | None
|
|
) -> RosterAgentPackageExport:
|
|
self.calls.append(("agent", workspace_id, agent_id, version_id))
|
|
return RosterAgentPackageExport(archive=BytesIO(b"agent"), filename="agent.ifpkg", size=5)
|
|
|
|
|
|
@dataclass
|
|
class Creators:
|
|
enabled: bool = True
|
|
failure: Literal["upload", "authorize"] | None = None
|
|
uploads: list[BinaryIO] = field(default_factory=list)
|
|
calls: list[tuple[object, ...]] = field(default_factory=list)
|
|
|
|
def require_enabled(self) -> None:
|
|
if not self.enabled:
|
|
raise CreatorsPlatformDisabledError
|
|
|
|
def upload(self, source: BinaryIO, *, filename: str) -> str:
|
|
self.uploads.append(source)
|
|
self.calls.append(("upload", filename, source.read()))
|
|
if self.failure == "upload":
|
|
raise RuntimeError("Upload failed")
|
|
return "claim"
|
|
|
|
def authorize(self, account_id: str) -> str | None:
|
|
assert all(source.closed for source in self.uploads)
|
|
self.calls.append(("authorize", account_id))
|
|
if self.failure == "authorize":
|
|
raise RuntimeError("Authorization failed")
|
|
return "code"
|
|
|
|
def redirect_url(self, claim_code: str, oauth_code: str | None) -> str:
|
|
self.calls.append(("redirect", claim_code, oauth_code))
|
|
return "https://creators.example.com"
|
|
|
|
|
|
class Tracing:
|
|
def set_trace(self, context: RequestContext, app_id: str, settings: AppTraceSettings) -> None:
|
|
del context, app_id
|
|
if settings.tracing_provider not in (None, "langfuse"):
|
|
raise ValueError("Invalid tracing provider")
|
|
|
|
|
|
Ports = tuple[ConsoleAppService, Apps, Access, Transfers, Creators]
|
|
|
|
|
|
@pytest.fixture
|
|
def ports() -> Ports:
|
|
apps, access, transfers, creators = Apps(), Access(), Transfers(), Creators()
|
|
service = ConsoleAppService(
|
|
apps=cast(ConsoleApps, apps),
|
|
access=access,
|
|
transfers=transfers,
|
|
creators=creators,
|
|
tracing=Tracing(),
|
|
lifecycle=Lifecycle(),
|
|
)
|
|
return service, apps, access, transfers, creators
|
|
|
|
|
|
def test_list_applies_visibility_and_enriches_materialized_records(ports: Ports) -> None:
|
|
service, apps, access, _, _ = ports
|
|
params = AppListParams(page=2, limit=5)
|
|
result = service.list_apps(CONTEXT, params)
|
|
_, context, filtered = apps.calls[0]
|
|
assert context == CONTEXT
|
|
assert isinstance(filtered, AppListParams)
|
|
assert filtered.accessible_app_ids == ["app"]
|
|
assert params.accessible_app_ids is None
|
|
assert result.page == 2
|
|
assert result.data[0].permission_keys == ["app.preview"]
|
|
assert result.data[0].access_mode == "private"
|
|
assert RECORD.access_mode is None
|
|
assert access.calls == [("permissions", CONTEXT, None), ("modes", ["app"])]
|
|
|
|
|
|
def test_empty_list_skips_external_enrichment(ports: Ports) -> None:
|
|
service, apps, access, _, _ = ports
|
|
apps.records = []
|
|
assert service.list_apps(CONTEXT, AppListParams()).data == []
|
|
assert access.calls == [("permissions", CONTEXT, None)]
|
|
|
|
|
|
def test_starred_list_preserves_existing_visibility_contract(ports: Ports) -> None:
|
|
service, apps, access, _, _ = ports
|
|
params = StarredAppListParams()
|
|
page = service.list_apps(CONTEXT, params)
|
|
assert page.data[0].permission_keys == ["app.preview"]
|
|
assert access.calls == [("permissions", CONTEXT, None), ("modes", ["app"])]
|
|
# visibility filtering is still skipped for starred lists
|
|
_, _, listed_params = apps.calls[0]
|
|
assert listed_params is params
|
|
|
|
|
|
def test_recent_uses_list_visibility_and_permission_keys(ports: Ports) -> None:
|
|
service, apps, _, _, _ = ports
|
|
assert service.recent(CONTEXT, 3)[0].permission_keys == ["app.preview"]
|
|
params = apps.calls[0][2]
|
|
assert isinstance(params, AppListParams)
|
|
assert params.limit == 3
|
|
assert params.accessible_app_ids == ["app"]
|
|
|
|
|
|
def test_detail_and_creation_have_distinct_permission_lookups(ports: Ports) -> None:
|
|
service, _, access, _, _ = ports
|
|
assert service.get(CONTEXT, "app").permission_keys == ["app.preview"]
|
|
assert access.calls == [("permissions", CONTEXT, "app")]
|
|
access.calls.clear()
|
|
assert service.create(CONTEXT, CreateAppParams(name="Example", mode="chat")).permission_keys == ["app.edit"]
|
|
assert access.calls == [("created_permissions", CONTEXT, "app"), ("initialize", CONTEXT, "app")]
|
|
|
|
|
|
@pytest.mark.parametrize("status", [ImportStatus.FAILED, ImportStatus.PENDING])
|
|
def test_copy_pending_or_failure_skips_created_permissions(ports: Ports, status: ImportStatus) -> None:
|
|
service, apps, access, transfers, _ = ports
|
|
transfers.status = status
|
|
result, copied = service.copy(CONTEXT, "app", CopyAppParams())
|
|
assert result.status == status
|
|
assert copied is None
|
|
assert apps.calls == []
|
|
assert access.calls == []
|
|
|
|
|
|
def test_copy_success_requires_materialized_app_and_enriches_permissions(ports: Ports) -> None:
|
|
service, apps, access, transfers, _ = ports
|
|
params = CopyAppParams(name="Copy", description="Description", icon_type=IconType.EMOJI, icon="robot")
|
|
_, copied = service.copy(CONTEXT, "app", params)
|
|
assert copied is not None
|
|
assert copied.permission_keys == ["app.edit"]
|
|
assert access.calls == [("inherit", "app", "app"), ("created_permissions", CONTEXT, "app")]
|
|
assert apps.calls == [("get", CONTEXT, "app")]
|
|
assert transfers.calls == [
|
|
("dsl", CONTEXT, "app", AppExportOptions(include_secret=True, preserve_agent_bindings=True)),
|
|
(
|
|
"copy",
|
|
CONTEXT,
|
|
AppImportParams(mode="yaml-content", yaml_content="app: example", **params.model_dump()),
|
|
),
|
|
]
|
|
transfers.copied_id = None
|
|
with pytest.raises(ConsoleAppNotFoundError):
|
|
service.copy(CONTEXT, "app", CopyAppParams())
|
|
|
|
|
|
def test_export_routes_yaml_ordinary_package_and_agent_package(ports: Ports) -> None:
|
|
service, apps, _, transfers, _ = ports
|
|
options = AppExportOptions(format="yaml", include_secret=True, workflow_id="workflow")
|
|
assert service.export(CONTEXT, "app", options) == "app: example"
|
|
assert transfers.calls[-1] == ("dsl", CONTEXT, "app", options)
|
|
archive = service.export(CONTEXT, "app", AppExportOptions())
|
|
assert transfers.calls[-1] == ("package", CONTEXT, "app", AppExportOptions())
|
|
assert isinstance(archive, RosterAgentPackageExport)
|
|
archive.close()
|
|
apps.source = replace(apps.source, mode="agent", bound_agent_id="agent")
|
|
version = uuid4()
|
|
archive = service.export(CONTEXT, "app", AppExportOptions(version_id=version))
|
|
assert transfers.calls[-1] == ("agent", "workspace", "agent", version)
|
|
assert isinstance(archive, RosterAgentPackageExport)
|
|
archive.close()
|
|
|
|
|
|
def test_export_rejects_invalid_or_unpaid_version_before_exporting(ports: Ports) -> None:
|
|
service, apps, access, transfers, _ = ports
|
|
options = AppExportOptions(version_id=uuid4())
|
|
with pytest.raises(InvalidAppExportError):
|
|
service.export(CONTEXT, "app", options)
|
|
apps.source = replace(apps.source, mode="agent", bound_agent_id="agent")
|
|
access.paid = False
|
|
with pytest.raises(AppExportPaidPlanRequiredError):
|
|
service.export(CONTEXT, "app", options)
|
|
assert transfers.calls == []
|
|
access.paid = True
|
|
apps.source = replace(apps.source, bound_agent_id=None)
|
|
with pytest.raises(ConsoleAppNotFoundError, match="Agent not found"):
|
|
service.export(CONTEXT, "app", AppExportOptions())
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["chat", "workflow", "agent"])
|
|
def test_publish_orders_package_upload_authorization_and_redirect_without_secrets(ports: Ports, mode: str) -> None:
|
|
service, apps, _, transfers, creators = ports
|
|
apps.source = replace(apps.source, mode=mode, bound_agent_id="agent" if mode == "agent" else None)
|
|
assert service.publish(CONTEXT, "app") == "https://creators.example.com"
|
|
if mode == "agent":
|
|
assert transfers.calls == [("agent", "workspace", "agent", None)]
|
|
filename, payload = "agent.ifpkg", b"agent"
|
|
else:
|
|
assert transfers.calls == [("package", CONTEXT, "app", AppExportOptions(format="ifpkg", include_secret=False))]
|
|
filename, payload = "example.ifpkg", b"package"
|
|
assert creators.calls == [("upload", filename, payload), ("authorize", "actor"), ("redirect", "claim", "code")]
|
|
assert len(creators.uploads) == 1
|
|
assert creators.uploads[0].closed
|
|
assert apps.calls == [("get_reference", CONTEXT, "app")]
|
|
|
|
|
|
@pytest.mark.parametrize("failure", ["upload", "authorize"])
|
|
def test_publish_closes_package_and_stops_after_failure(ports: Ports, failure: Literal["upload", "authorize"]) -> None:
|
|
service, _, _, _, creators = ports
|
|
creators.failure = failure
|
|
with pytest.raises(RuntimeError, match="failed"):
|
|
service.publish(CONTEXT, "app")
|
|
assert creators.uploads[0].closed
|
|
expected: list[tuple[object, ...]] = [("upload", "example.ifpkg", b"package")]
|
|
if failure == "authorize":
|
|
expected.append(("authorize", "actor"))
|
|
assert creators.calls == expected
|
|
|
|
|
|
def test_publish_rejects_unbound_agent_before_upload(ports: Ports) -> None:
|
|
service, apps, _, transfers, creators = ports
|
|
apps.source = replace(apps.source, mode="agent")
|
|
with pytest.raises(ConsoleAppNotFoundError, match="Agent not found"):
|
|
service.publish(CONTEXT, "app")
|
|
assert transfers.calls == creators.calls == []
|
|
|
|
|
|
def test_disabled_creators_has_no_export_or_external_effects(ports: Ports) -> None:
|
|
service, _, _, transfers, creators = ports
|
|
creators.enabled = False
|
|
with pytest.raises(CreatorsPlatformDisabledError):
|
|
service.publish(CONTEXT, "app")
|
|
assert transfers.calls == creators.calls == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("enabled", "provider"),
|
|
[
|
|
pytest.param(False, None, id="disabled-without-provider"),
|
|
pytest.param(False, "langfuse", id="disabled-with-provider"),
|
|
pytest.param(True, "langfuse", id="enabled"),
|
|
pytest.param(True, "invalid", id="invalid"),
|
|
],
|
|
)
|
|
def test_trace_update_checks_ownership_and_provider_without_parsing_old_settings(
|
|
ports: Ports, enabled: bool, provider: str | None
|
|
) -> None:
|
|
service, apps, _, _, _ = ports
|
|
settings = AppTraceSettings(enabled, provider)
|
|
if provider == "invalid":
|
|
with pytest.raises(ValueError, match="Invalid tracing provider"):
|
|
service.set_trace(CONTEXT, "app", settings)
|
|
assert apps.calls == [("get_reference", CONTEXT, "app")]
|
|
else:
|
|
service.set_trace(CONTEXT, "app", settings)
|
|
assert apps.calls == [("get_reference", CONTEXT, "app")]
|
|
|
|
|
|
class Lifecycle:
|
|
def delete(self, context: RequestContext, app_id: str) -> AppDeletion:
|
|
del context, app_id
|
|
pytest.fail("Unexpected app deletion")
|
|
|
|
def create(self, context: RequestContext, params: CreateAppParams, settings: AppCreationSettings) -> AppRecord:
|
|
assert context is CONTEXT
|
|
assert settings.app == {"mode": params.mode}
|
|
return RECORD
|
|
|
|
def create_draft(self, context: RequestContext, app_id: str) -> None:
|
|
del context, app_id
|
|
pytest.fail("Unexpected draft creation")
|
|
|
|
def prepare_creation(self, context: RequestContext, params: CreateAppParams) -> AppCreationSettings:
|
|
assert context is CONTEXT
|
|
return AppCreationSettings({"mode": params.mode}, None)
|
|
|
|
def created(self, context: RequestContext, app: AppRecord) -> None:
|
|
assert context is CONTEXT
|
|
assert app.id == "app"
|
|
|
|
def updated(self, context: RequestContext, app: AppRecord) -> None:
|
|
assert context is CONTEXT
|
|
assert app.id == "app"
|
|
|
|
def deleted(self, context: RequestContext, deleted: AppDeletion) -> None:
|
|
assert context is CONTEXT
|
|
assert deleted.app.id == "app"
|
|
|
|
def present(self, context: RequestContext, app: AppRecord, *, mask_credentials: bool = False) -> AppRecord:
|
|
del mask_credentials
|
|
assert context is CONTEXT
|
|
return app
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("provider_name", "expected_model"),
|
|
[
|
|
pytest.param(
|
|
"langgenius/openai/openai",
|
|
("langgenius/openai", "langgenius/openai/openai", "gpt-4o"),
|
|
id="workspace-default",
|
|
),
|
|
pytest.param(None, None, id="no-default"),
|
|
pytest.param("invalid/provider", None, id="invalid-provider"),
|
|
],
|
|
)
|
|
def test_lifecycle_agent_creation_seeds_workspace_default_model(
|
|
sqlite_engine: Engine,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
provider_name: str | None,
|
|
expected_model: tuple[str, str, str] | None,
|
|
) -> None:
|
|
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False, close_resets_only=False)
|
|
with factory.begin() as session:
|
|
account = Account(name="Creator", email=f"{uuid4()}@example.com")
|
|
tenant = Tenant(name="Workspace")
|
|
session.add_all(
|
|
[
|
|
account,
|
|
tenant,
|
|
TenantAccountJoin(
|
|
tenant_id=tenant.id,
|
|
account_id=account.id,
|
|
current=True,
|
|
role=TenantAccountRole.OWNER,
|
|
),
|
|
]
|
|
)
|
|
if provider_name is not None:
|
|
session.add(
|
|
TenantDefaultModel(
|
|
tenant_id=tenant.id,
|
|
model_type=ModelType.LLM,
|
|
provider_name=provider_name,
|
|
model_name="gpt-4o",
|
|
)
|
|
)
|
|
|
|
if provider_name is None:
|
|
monkeypatch.setattr(
|
|
ModelProviderService,
|
|
"get_default_model_selection",
|
|
lambda *_args, **_kwargs: None,
|
|
)
|
|
|
|
context = RequestContext("request", None, account.id, tenant.id)
|
|
app = AppLifecycleGateway(session_factory=factory).create(
|
|
context,
|
|
CreateAppParams(name="Agent", mode=AppMode.AGENT.value),
|
|
AppCreationSettings({"enable_site": False, "enable_api": False}, None),
|
|
)
|
|
|
|
with factory() as session:
|
|
agent = session.scalar(select(Agent).where(Agent.app_id == app.id))
|
|
assert agent is not None
|
|
snapshot = session.get(AgentConfigSnapshot, agent.active_config_snapshot_id)
|
|
assert snapshot is not None
|
|
model = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict).model
|
|
if expected_model is None:
|
|
assert model is None
|
|
assert agent.active_config_has_model is False
|
|
else:
|
|
assert model is not None
|
|
assert (model.plugin_id, model.model_provider, model.model) == expected_model
|
|
assert agent.active_config_has_model is True
|
|
|
|
|
|
@pytest.mark.parametrize("failure", [None, "persist", "external"])
|
|
@pytest.mark.parametrize("mode", [AppMode.WORKFLOW, AppMode.AGENT])
|
|
def test_lifecycle_runs_after_atomic_creation_and_session_close(
|
|
sqlite_engine: Engine, failure: str | None, mode: AppMode
|
|
) -> None:
|
|
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False, close_resets_only=False)
|
|
with factory.begin() as session:
|
|
account = Account(name="Creator", email=f"{uuid4()}@example.com")
|
|
tenant = Tenant(name="Workspace")
|
|
session.add_all(
|
|
[
|
|
account,
|
|
tenant,
|
|
TenantAccountJoin(
|
|
tenant_id=tenant.id,
|
|
account_id=account.id,
|
|
current=True,
|
|
role=TenantAccountRole.OWNER,
|
|
),
|
|
]
|
|
)
|
|
context = RequestContext("request", None, account.id, tenant.id)
|
|
effects: list[str] = []
|
|
checked_out: set[object] = set()
|
|
|
|
def checkout(connection: object, *_args: object) -> None:
|
|
checked_out.add(connection)
|
|
|
|
def checkin(connection: object, *_args: object) -> None:
|
|
checked_out.remove(connection)
|
|
|
|
def reject_commit(_session: Session) -> None:
|
|
raise RuntimeError("persist failed")
|
|
|
|
class LifecycleProbe(AppLifecycleGateway):
|
|
@override
|
|
def prepare_creation(self, context: RequestContext, params: CreateAppParams) -> AppCreationSettings:
|
|
assert context.account_id == account.id
|
|
assert not checked_out
|
|
assert params.mode == mode
|
|
return AppCreationSettings({"enable_site": True, "enable_api": True}, None)
|
|
|
|
@override
|
|
def present(self, context: RequestContext, app: AppRecord, *, mask_credentials: bool = False) -> AppRecord:
|
|
assert context.account_id == account.id
|
|
assert not mask_credentials
|
|
return app
|
|
|
|
@override
|
|
def created(self, context: RequestContext, app: AppRecord) -> None:
|
|
assert context.account_id == account.id
|
|
assert not checked_out
|
|
with factory() as read:
|
|
assert read.get(App, app.id) is not None
|
|
assert read.scalar(select(Site.id).where(Site.app_id == app.id)) is not None
|
|
assert read.scalar(select(InstalledApp.id).where(InstalledApp.app_id == app.id)) is not None
|
|
backing_agent = read.scalar(select(Agent).where(Agent.app_id == app.id))
|
|
assert (backing_agent is not None) == (mode == AppMode.AGENT)
|
|
effects.append("created")
|
|
if failure == "external":
|
|
raise RuntimeError("external failed")
|
|
|
|
@override
|
|
def updated(self, context: RequestContext, app: AppRecord) -> None:
|
|
assert context.account_id == account.id
|
|
assert not checked_out
|
|
with factory() as read:
|
|
persisted = read.get(App, app.id)
|
|
assert persisted is not None
|
|
assert persisted.name == "Renamed"
|
|
effects.append("updated")
|
|
|
|
@override
|
|
def deleted(self, context: RequestContext, deleted: AppDeletion) -> None:
|
|
assert context.account_id == account.id
|
|
assert not checked_out
|
|
with factory() as read:
|
|
assert read.get(App, deleted.app.id) is None
|
|
if mode == AppMode.AGENT:
|
|
assert (
|
|
read.scalar(select(Agent.status).where(Agent.app_id == deleted.app.id)) == AgentStatus.ARCHIVED
|
|
)
|
|
effects.append("deleted")
|
|
|
|
access = Access()
|
|
service = ConsoleAppService(
|
|
apps=ConsoleAppRepository(session_factory=factory),
|
|
access=access,
|
|
transfers=Transfers(),
|
|
creators=Creators(),
|
|
tracing=Tracing(),
|
|
lifecycle=LifecycleProbe(session_factory=factory),
|
|
)
|
|
event.listen(sqlite_engine, "checkout", checkout)
|
|
event.listen(sqlite_engine, "checkin", checkin)
|
|
if failure == "persist":
|
|
event.listen(factory, "before_commit", reject_commit)
|
|
try:
|
|
params = CreateAppParams.model_validate({"name": "Created", "mode": mode})
|
|
if failure:
|
|
with pytest.raises(RuntimeError, match=f"{failure} failed"):
|
|
service.create(context, params)
|
|
with factory() as read:
|
|
expected = 0 if failure == "persist" else 1
|
|
for model in (App, Site, InstalledApp):
|
|
assert read.scalar(select(func.count()).select_from(model)) == expected
|
|
assert read.scalar(select(func.count()).select_from(Agent)) == (
|
|
expected if mode == AppMode.AGENT else 0
|
|
)
|
|
assert effects == ([] if failure == "persist" else ["created"])
|
|
assert access.calls == []
|
|
else:
|
|
app = service.create(context, params)
|
|
service.rename(context, app.id, "Renamed")
|
|
service.delete(context, app.id)
|
|
assert effects == ["created", "updated", "deleted"]
|
|
assert not checked_out
|
|
finally:
|
|
if failure == "persist":
|
|
event.remove(factory, "before_commit", reject_commit)
|
|
event.remove(sqlite_engine, "checkout", checkout)
|
|
event.remove(sqlite_engine, "checkin", checkin)
|
|
|
|
|
|
def test_app_deletion_failure_rolls_back_backing_agent_changes(sqlite_engine: Engine) -> None:
|
|
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False, close_resets_only=False)
|
|
with factory.begin() as session:
|
|
account = Account(name="Creator", email=f"{uuid4()}@example.com")
|
|
tenant = Tenant(name="Workspace")
|
|
session.add_all(
|
|
[
|
|
account,
|
|
tenant,
|
|
TenantAccountJoin(
|
|
tenant_id=tenant.id, account_id=account.id, current=True, role=TenantAccountRole.OWNER
|
|
),
|
|
]
|
|
)
|
|
context = RequestContext("request", None, account.id, tenant.id)
|
|
gateway = AppLifecycleGateway(session_factory=factory)
|
|
app = gateway.create(
|
|
context,
|
|
CreateAppParams(name="Agent", mode="agent"),
|
|
AppCreationSettings({"enable_site": False, "enable_api": False}, None),
|
|
)
|
|
|
|
def reject_commit(_session: Session) -> None:
|
|
raise RuntimeError("commit failed")
|
|
|
|
event.listen(factory, "before_commit", reject_commit)
|
|
try:
|
|
with pytest.raises(RuntimeError, match="commit failed"):
|
|
gateway.delete(context, app.id)
|
|
finally:
|
|
event.remove(factory, "before_commit", reject_commit)
|
|
|
|
with factory() as read:
|
|
assert read.get(App, app.id) is not None
|
|
agent = read.scalar(select(Agent).where(Agent.app_id == app.id))
|
|
assert agent is not None
|
|
assert agent.status == AgentStatus.ACTIVE
|
|
assert agent.archived_at is None
|
|
|
|
|
|
@pytest.mark.parametrize("operation", ["get", "update", "create", "copy"])
|
|
@pytest.mark.parametrize("remote_fails", [False, True])
|
|
def test_detail_tool_enrichment_releases_database_before_plugin_io(
|
|
sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch, operation: str, remote_fails: bool
|
|
) -> None:
|
|
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False, close_resets_only=False)
|
|
missing_api = str(uuid4())
|
|
with factory.begin() as session:
|
|
account = Account(name="Creator", email=f"{uuid4()}@example.com")
|
|
tenant = Tenant(name="Workspace")
|
|
session.add_all(
|
|
[
|
|
account,
|
|
tenant,
|
|
TenantAccountJoin(
|
|
tenant_id=tenant.id,
|
|
account_id=account.id,
|
|
current=True,
|
|
role=TenantAccountRole.OWNER,
|
|
),
|
|
]
|
|
)
|
|
providers = [
|
|
ApiToolProvider(
|
|
name="API",
|
|
icon="",
|
|
schema="{}",
|
|
schema_type_str=ApiProviderSchemaType.OPENAPI,
|
|
user_id=account.id,
|
|
tenant_id=workspace,
|
|
description="",
|
|
tools_str="[]",
|
|
credentials_str="{}",
|
|
)
|
|
for workspace in (tenant.id, str(uuid4()))
|
|
]
|
|
session.add_all(providers)
|
|
tools = [
|
|
{
|
|
"provider_type": kind,
|
|
"provider_id": provider_id,
|
|
"tool_name": "tool",
|
|
"tool_parameters": dict[str, str](),
|
|
}
|
|
for kind, provider_id in (
|
|
("api", providers[0].id),
|
|
("api", missing_api),
|
|
("api", providers[1].id),
|
|
("builtin", "vendor/missing/tool"),
|
|
("builtin", "vendor/installed/tool"),
|
|
("builtin", "local"),
|
|
)
|
|
]
|
|
config_values = {"agent_mode": json.dumps({"enabled": False, "tools": tools}), "model": "{}"}
|
|
app = App(
|
|
id=str(uuid4()), tenant_id=tenant.id, name="Example", mode=AppMode.CHAT, enable_site=True, enable_api=True
|
|
)
|
|
config = AppModelConfig(app_id=app.id, agent_mode=config_values["agent_mode"], model=config_values["model"])
|
|
session.add_all([app, config])
|
|
session.flush()
|
|
app.app_model_config_id = config.id
|
|
context = RequestContext("request", None, account.id, tenant.id)
|
|
repository = ConsoleAppRepository(session_factory=factory)
|
|
checked_out: set[object] = set()
|
|
remote_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 hardcoded(provider: str) -> object:
|
|
assert not checked_out
|
|
if provider == "local":
|
|
return object()
|
|
raise ValueError("Not a hardcoded provider")
|
|
|
|
def check_plugins(_self: PluginInstaller, tenant_id: str, provider_ids: Sequence[GenericProviderID]) -> list[bool]:
|
|
assert tenant_id == tenant.id
|
|
assert not checked_out, "Plugin daemon I/O must run after returning the database connection"
|
|
remote_calls.append([str(provider) for provider in provider_ids])
|
|
if remote_fails:
|
|
raise RuntimeError("Plugin daemon unavailable")
|
|
return [False, True]
|
|
|
|
class LifecycleGateway(AppLifecycleGateway):
|
|
@override
|
|
def prepare_creation(self, context: RequestContext, params: CreateAppParams) -> AppCreationSettings:
|
|
assert context.account_id == account.id
|
|
assert params.mode == "chat"
|
|
return AppCreationSettings({"enable_site": True, "enable_api": True}, config_values)
|
|
|
|
@override
|
|
def created(self, context: RequestContext, app: AppRecord) -> None:
|
|
assert context.account_id == account.id
|
|
assert app.created_by == account.id
|
|
assert not checked_out
|
|
|
|
class CopyTransfer(Transfers):
|
|
@override
|
|
def import_copy(self, context: RequestContext, params: AppImportParams) -> Import:
|
|
assert params.name is None
|
|
return Import(id="import", status=ImportStatus.COMPLETED, app_id=app.id)
|
|
|
|
service = ConsoleAppService(
|
|
apps=repository,
|
|
access=Access(),
|
|
transfers=CopyTransfer(),
|
|
creators=Creators(),
|
|
tracing=Tracing(),
|
|
lifecycle=LifecycleGateway(session_factory=factory),
|
|
)
|
|
monkeypatch.setattr(ToolManager, "get_hardcoded_provider", hardcoded)
|
|
monkeypatch.setattr(PluginInstaller, "check_tools_existence", check_plugins)
|
|
event.listen(sqlite_engine, "checkout", checkout)
|
|
event.listen(sqlite_engine, "checkin", checkin)
|
|
|
|
def read_result() -> AppRecord:
|
|
if operation == "get":
|
|
return service.get(context, app.id)
|
|
if operation == "update":
|
|
return service.update(context, app.id, UpdateAppParams(name="Updated"))
|
|
if operation == "create":
|
|
return service.create(context, CreateAppParams(name="Created", mode="chat"))
|
|
_, copied = service.copy(context, app.id, CopyAppParams())
|
|
assert copied is not None
|
|
return copied
|
|
|
|
try:
|
|
snapshot = repository.get(context, app.id)
|
|
assert remote_calls == []
|
|
assert not checked_out
|
|
assert [ref.exists for ref in snapshot.tool_references] == [True, False, False, None, None, None]
|
|
if remote_fails:
|
|
with pytest.raises(RuntimeError, match="Plugin daemon unavailable"):
|
|
read_result()
|
|
with factory() as read:
|
|
if operation == "update":
|
|
assert read.scalar(select(App.name).where(App.id == app.id)) == "Updated"
|
|
if operation == "create":
|
|
assert read.scalar(select(App.id).where(App.name == "Created")) is not None
|
|
else:
|
|
result = read_result()
|
|
assert result.deleted_tools == [
|
|
{"type": "api", "provider_id": missing_api, "tool_name": "tool"},
|
|
{"type": "api", "provider_id": providers[1].id, "tool_name": "tool"},
|
|
{"type": "builtin", "provider_id": "vendor/missing/tool", "tool_name": "tool"},
|
|
]
|
|
assert remote_calls == [["vendor/missing/tool", "vendor/installed/tool"]]
|
|
assert not checked_out
|
|
finally:
|
|
event.remove(sqlite_engine, "checkout", checkout)
|
|
event.remove(sqlite_engine, "checkin", checkin)
|
|
|
|
|
|
@pytest.mark.parametrize("operation", ["rename", "icon", "site"])
|
|
@pytest.mark.parametrize("commit_fails", [False, True])
|
|
def test_app_mutations_publish_only_after_commit(sqlite_engine: Engine, operation: str, commit_fails: bool) -> None:
|
|
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False, close_resets_only=False)
|
|
context = RequestContext("request", None, str(uuid4()), str(uuid4()))
|
|
with factory.begin() as session:
|
|
app = App(
|
|
tenant_id=context.active_workspace_id,
|
|
name="Before",
|
|
mode=AppMode.COMPLETION,
|
|
icon_type=IconType.IMAGE,
|
|
icon="old",
|
|
icon_background="#fff",
|
|
enable_site=False,
|
|
enable_api=False,
|
|
)
|
|
session.add(app)
|
|
service = ConsoleAppService(
|
|
apps=ConsoleAppRepository(session_factory=factory),
|
|
access=Access(),
|
|
transfers=Transfers(),
|
|
creators=Creators(),
|
|
tracing=Tracing(),
|
|
lifecycle=AppLifecycleGateway(session_factory=factory),
|
|
)
|
|
signals: list[str] = []
|
|
checked_out: set[object] = set()
|
|
|
|
def checkout(connection: object, *_args: object) -> None:
|
|
checked_out.add(connection)
|
|
|
|
def checkin(connection: object, *_args: object) -> None:
|
|
checked_out.remove(connection)
|
|
|
|
def after_update(_sender: object) -> None:
|
|
assert not checked_out
|
|
with factory() as read:
|
|
persisted = read.get(App, app.id)
|
|
assert persisted is not None
|
|
assert persisted.updated_by == context.account_id
|
|
assert (persisted.name, persisted.icon, persisted.enable_site) == (
|
|
"After" if operation == "rename" else "Before",
|
|
"new" if operation == "icon" else "old",
|
|
operation == "site",
|
|
)
|
|
assert persisted.icon_type == "image"
|
|
signals.append("updated")
|
|
|
|
def reject_commit(_session: Session) -> None:
|
|
raise RuntimeError("commit failed")
|
|
|
|
def update() -> AppRecord:
|
|
if operation == "rename":
|
|
return service.rename(context, app.id, "After")
|
|
if operation == "icon":
|
|
return service.update_icon(context, app.id, icon="new", icon_background="#000", icon_type=None)
|
|
return service.set_site_enabled(context, app.id, True)
|
|
|
|
app_was_updated.connect(after_update)
|
|
event.listen(sqlite_engine, "checkout", checkout)
|
|
event.listen(sqlite_engine, "checkin", checkin)
|
|
if commit_fails:
|
|
event.listen(factory, "before_commit", reject_commit)
|
|
try:
|
|
if commit_fails:
|
|
with pytest.raises(RuntimeError, match="commit failed"):
|
|
update()
|
|
with factory() as read:
|
|
persisted = read.get(App, app.id)
|
|
assert persisted is not None
|
|
assert (persisted.name, persisted.icon, persisted.enable_site) == ("Before", "old", False)
|
|
assert signals == []
|
|
else:
|
|
result = update()
|
|
assert result.updated_by == context.account_id
|
|
if operation == "site":
|
|
assert service.set_site_enabled(context, app.id, True).enable_site
|
|
assert signals == ["updated"]
|
|
assert not checked_out
|
|
finally:
|
|
if commit_fails:
|
|
event.remove(factory, "before_commit", reject_commit)
|
|
event.remove(sqlite_engine, "checkout", checkout)
|
|
event.remove(sqlite_engine, "checkin", checkin)
|
|
app_was_updated.disconnect(after_update)
|
|
|
|
|
|
@pytest.mark.parametrize("surface", ["site", "api"])
|
|
def test_unpublished_agent_cannot_enable_access_through_console_service(sqlite_engine: Engine, surface: str) -> None:
|
|
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
|
context = RequestContext("request", None, str(uuid4()), str(uuid4()))
|
|
with factory.begin() as session:
|
|
app = App(
|
|
id=str(uuid4()),
|
|
tenant_id=context.active_workspace_id,
|
|
name="Agent",
|
|
mode=AppMode.AGENT,
|
|
enable_site=False,
|
|
enable_api=False,
|
|
)
|
|
session.add(app)
|
|
session.flush()
|
|
AgentRosterService(session).create_backing_agent_for_app(
|
|
tenant_id=context.active_workspace_id,
|
|
account_id=context.account_id,
|
|
app_id=app.id,
|
|
name="Agent",
|
|
)
|
|
service = ConsoleAppService(
|
|
apps=ConsoleAppRepository(session_factory=factory),
|
|
access=Access(),
|
|
transfers=Transfers(),
|
|
creators=Creators(),
|
|
tracing=Tracing(),
|
|
lifecycle=AppLifecycleGateway(session_factory=factory),
|
|
)
|
|
signals: list[object] = []
|
|
|
|
def updated(sender: object) -> None:
|
|
signals.append(sender)
|
|
|
|
app_was_updated.connect(updated)
|
|
set_enabled = service.set_site_enabled if surface == "site" else service.set_api_enabled
|
|
try:
|
|
with pytest.raises(AgentAccessNotReadyError):
|
|
set_enabled(context, app.id, True)
|
|
assert signals == []
|
|
with factory() as read:
|
|
persisted = read.get(App, app.id)
|
|
assert persisted is not None
|
|
assert not persisted.enable_site
|
|
assert not persisted.enable_api
|
|
finally:
|
|
app_was_updated.disconnect(updated)
|
|
|
|
|
|
_TOOL_NAME = "summarize"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Workspaces:
|
|
source: RequestContext
|
|
target: RequestContext
|
|
caller_app_id: str
|
|
tool_app_id: str
|
|
provider_id: str
|
|
|
|
|
|
def _no_plugin_dependencies(*, tenant_id: str, dependencies: list[str]) -> list[PluginDependency]:
|
|
del tenant_id
|
|
assert dependencies == []
|
|
return []
|
|
|
|
|
|
def _features_with_one_free_app(tenant_id: str, *, exclude_vector_space: bool) -> FeatureModel:
|
|
del tenant_id, exclude_vector_space
|
|
return FeatureModel(apps=LimitationModel(size=4, limit=5))
|
|
|
|
|
|
def _workflow(*, tenant_id: str, app_id: str, version: str, graph: dict[str, object], created_by: str) -> Workflow:
|
|
return Workflow(
|
|
id=str(uuid4()),
|
|
tenant_id=tenant_id,
|
|
app_id=app_id,
|
|
type=WorkflowType.WORKFLOW,
|
|
version=version,
|
|
graph=json.dumps(graph),
|
|
_features=json.dumps({}),
|
|
_environment_variables=[],
|
|
_conversation_variables=[],
|
|
created_by=created_by,
|
|
)
|
|
|
|
|
|
def _workflow_tool(
|
|
*, provider_id: str, tenant_id: str, app_id: str, version: str, user_id: str
|
|
) -> WorkflowToolProvider:
|
|
provider = WorkflowToolProvider(
|
|
name=_TOOL_NAME,
|
|
label="Summarize",
|
|
icon=json.dumps({"background": "#FFEAD5", "content": "🤖"}),
|
|
app_id=app_id,
|
|
version=version,
|
|
user_id=user_id,
|
|
tenant_id=tenant_id,
|
|
description="Summarize a document",
|
|
parameter_configuration="[]",
|
|
)
|
|
provider.id = provider_id
|
|
return provider
|
|
|
|
|
|
def _tool_node(provider_id: str, tool_name: str) -> dict[str, object]:
|
|
return {
|
|
"id": tool_name,
|
|
"data": {
|
|
"type": "tool",
|
|
"title": tool_name,
|
|
"provider_id": provider_id,
|
|
"provider_type": "workflow",
|
|
"provider_name": tool_name,
|
|
"tool_name": tool_name,
|
|
"tool_label": tool_name,
|
|
"tool_configurations": dict[str, object](),
|
|
"tool_parameters": dict[str, object](),
|
|
},
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def workspaces(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
sqlite_engine: Engine,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
config_overrides: Callable[..., None],
|
|
) -> Workspaces:
|
|
config_overrides(RBAC_ENABLED=False, DEPLOYMENT_EDITION="COMMUNITY")
|
|
# Workflow Tool management opens its own sessions from the Flask-SQLAlchemy engine.
|
|
monkeypatch.setattr(type(db), "engine", property(lambda _: sqlite_engine))
|
|
# Plugin metadata is resolved by the plugin daemon; these Apps use no plugins.
|
|
monkeypatch.setattr(DependenciesAnalysisService, "generate_dependencies", _no_plugin_dependencies)
|
|
# Workflow event handlers reach the Flask-bound database; these tests check the imported records.
|
|
monkeypatch.setattr("services.workflow_service.app_draft_workflow_was_synced", Signal())
|
|
monkeypatch.setattr("services.workflow_service.app_published_workflow_was_updated", Signal())
|
|
actor, source_id, target_id = str(uuid4()), str(uuid4()), str(uuid4())
|
|
caller_app_id, tool_app_id, provider_id = str(uuid4()), str(uuid4()), str(uuid4())
|
|
version = Workflow.version_from_datetime(naive_utc_now())
|
|
published = _workflow(
|
|
tenant_id=source_id,
|
|
app_id=tool_app_id,
|
|
version=version,
|
|
graph={"nodes": list[object](), "edges": list[object]()},
|
|
created_by=actor,
|
|
)
|
|
caller_graph: dict[str, object] = {"nodes": [_tool_node(provider_id, _TOOL_NAME)], "edges": list[object]()}
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add_all(
|
|
[
|
|
make_account(account_id=actor, name="Creator", email=f"{actor}@example.com"),
|
|
make_tenant(tenant_id=source_id, name="Source"),
|
|
make_tenant(tenant_id=target_id, name="Target"),
|
|
TenantAccountJoin(tenant_id=source_id, account_id=actor, role=TenantAccountRole.OWNER),
|
|
TenantAccountJoin(tenant_id=target_id, account_id=actor, role=TenantAccountRole.OWNER),
|
|
App(
|
|
id=tool_app_id,
|
|
tenant_id=source_id,
|
|
name="Summarizer",
|
|
mode=AppMode.WORKFLOW,
|
|
enable_site=True,
|
|
enable_api=True,
|
|
workflow_id=published.id,
|
|
),
|
|
published,
|
|
_workflow_tool(
|
|
provider_id=provider_id, tenant_id=source_id, app_id=tool_app_id, version=version, user_id=actor
|
|
),
|
|
ToolLabelBinding(tool_id=provider_id, tool_type=ToolProviderType.WORKFLOW, label_name="productivity"),
|
|
App(
|
|
id=caller_app_id,
|
|
tenant_id=source_id,
|
|
name="Caller",
|
|
mode=AppMode.WORKFLOW,
|
|
enable_site=True,
|
|
enable_api=True,
|
|
),
|
|
_workflow(
|
|
tenant_id=source_id,
|
|
app_id=caller_app_id,
|
|
version=Workflow.VERSION_DRAFT,
|
|
graph=caller_graph,
|
|
created_by=actor,
|
|
),
|
|
]
|
|
)
|
|
return Workspaces(
|
|
source=RequestContext("request", None, actor, source_id),
|
|
target=RequestContext("request", None, actor, target_id),
|
|
caller_app_id=caller_app_id,
|
|
tool_app_id=tool_app_id,
|
|
provider_id=provider_id,
|
|
)
|
|
|
|
|
|
def _export_and_import(
|
|
app_services: AppServices, workspaces: Workspaces, context: RequestContext, params: AppImportParams
|
|
) -> Import:
|
|
exported = app_services.console.export(workspaces.source, workspaces.caller_app_id, AppExportOptions())
|
|
assert not isinstance(exported, str)
|
|
with exported:
|
|
return app_services.console.import_app(context, params, source=exported.archive)
|
|
|
|
|
|
def _caller_tool_node(session: Session, app_id: str) -> dict[str, object]:
|
|
draft = session.scalar(
|
|
select(Workflow).where(Workflow.app_id == app_id, Workflow.version == Workflow.VERSION_DRAFT)
|
|
)
|
|
assert draft is not None
|
|
[node] = draft.graph_dict["nodes"]
|
|
return node["data"]
|
|
|
|
|
|
def _call_ranker_from_summarizer(
|
|
sqlite_session_factory: sessionmaker[Session], workspaces: Workspaces, ranker_nodes: list[dict[str, object]]
|
|
) -> str:
|
|
"""Make the summarizer call a second Workflow Tool, ``rank``, whose published workflow runs ``ranker_nodes``."""
|
|
source_id, actor = workspaces.source.active_workspace_id, workspaces.source.account_id
|
|
ranker_app_id, ranker_provider_id = str(uuid4()), str(uuid4())
|
|
version = Workflow.version_from_datetime(naive_utc_now())
|
|
ranker_workflow = _workflow(
|
|
tenant_id=source_id,
|
|
app_id=ranker_app_id,
|
|
version=version,
|
|
graph={"nodes": ranker_nodes, "edges": list[object]()},
|
|
created_by=actor,
|
|
)
|
|
ranker = _workflow_tool(
|
|
provider_id=ranker_provider_id, tenant_id=source_id, app_id=ranker_app_id, version=version, user_id=actor
|
|
)
|
|
ranker.name = "rank"
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add_all(
|
|
[
|
|
App(
|
|
id=ranker_app_id,
|
|
tenant_id=source_id,
|
|
name="Ranker",
|
|
mode=AppMode.WORKFLOW,
|
|
enable_site=True,
|
|
enable_api=True,
|
|
workflow_id=ranker_workflow.id,
|
|
),
|
|
ranker_workflow,
|
|
ranker,
|
|
]
|
|
)
|
|
summarizer = session.scalar(
|
|
select(Workflow).where(
|
|
Workflow.app_id == workspaces.tool_app_id, Workflow.version != Workflow.VERSION_DRAFT
|
|
)
|
|
)
|
|
assert summarizer is not None
|
|
summarizer.graph = json.dumps({"nodes": [_tool_node(ranker_provider_id, "rank")], "edges": list[object]()})
|
|
return ranker_provider_id
|
|
|
|
|
|
def test_export_bundles_the_pinned_workflow_and_its_tool_definition(
|
|
app_services: AppServices, workspaces: Workspaces
|
|
) -> None:
|
|
exported = app_services.console.export(workspaces.source, workspaces.caller_app_id, AppExportOptions())
|
|
assert not isinstance(exported, str)
|
|
with exported, ZipFile(exported.archive) as archive:
|
|
manifest = yaml.safe_load(archive.read("manifest.yaml"))
|
|
assert manifest["format_version"] == 2
|
|
[tool] = manifest["workflow_tools"]
|
|
assert (tool["provider_id"], tool["name"], tool["labels"]) == (
|
|
workspaces.provider_id,
|
|
_TOOL_NAME,
|
|
["productivity"],
|
|
)
|
|
with ZipFile(archive.open(tool["path"])) as nested:
|
|
assert yaml.safe_load(nested.read("manifest.yaml"))["format_version"] == 2
|
|
assert yaml.safe_load(nested.read("app.yaml"))["app"]["name"] == "Summarizer"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("maintained_by_caller", "allowed", "bundled"),
|
|
[(False, False, False), (False, True, True), (True, False, True)],
|
|
ids=["denied", "allowed", "maintainer"],
|
|
)
|
|
def test_export_bundles_only_workflows_the_caller_may_export(
|
|
app_services: AppServices,
|
|
workspaces: Workspaces,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
rbac_domain: RBACDomain,
|
|
config_overrides: Callable[..., None],
|
|
maintained_by_caller: bool,
|
|
allowed: bool,
|
|
bundled: bool,
|
|
) -> None:
|
|
config_overrides(RBAC_ENABLED=True)
|
|
rbac_domain.transport.response = {"allowed": allowed}
|
|
if maintained_by_caller:
|
|
with sqlite_session_factory.begin() as session:
|
|
tool_app = session.get(App, workspaces.tool_app_id)
|
|
assert tool_app is not None
|
|
tool_app.maintainer = workspaces.source.account_id
|
|
|
|
exported = app_services.console.export(workspaces.source, workspaces.caller_app_id, AppExportOptions())
|
|
|
|
assert not isinstance(exported, str)
|
|
with exported, ZipFile(exported.archive) as archive:
|
|
manifest = yaml.safe_load(archive.read("manifest.yaml"))
|
|
dsl = yaml.safe_load(archive.read("app.yaml"))
|
|
assert manifest["format_version"] == 2
|
|
assert [tool["provider_id"] for tool in manifest.get("workflow_tools", [])] == (
|
|
[workspaces.provider_id] if bundled else []
|
|
)
|
|
# A workflow that is not bundled stays a plain reference, as exports were before packages carried workflows.
|
|
assert dsl["workflow"]["graph"]["nodes"][0]["data"]["provider_id"] == workspaces.provider_id
|
|
checked_apps = [request.json["resource_id"] for request in rbac_domain.transport.requests if request.json]
|
|
assert checked_apps == ([] if maintained_by_caller else [workspaces.tool_app_id])
|
|
|
|
|
|
def test_import_into_another_workspace_recreates_the_called_workflow_as_a_tool(
|
|
app_services: AppServices, workspaces: Workspaces, sqlite_session_factory: sessionmaker[Session]
|
|
) -> None:
|
|
result = _export_and_import(app_services, workspaces, workspaces.target, AppImportParams(mode="yaml-content"))
|
|
|
|
assert result.status == ImportStatus.COMPLETED
|
|
target_id = workspaces.target.active_workspace_id
|
|
with sqlite_session_factory() as session:
|
|
[provider] = session.scalars(select(WorkflowToolProvider).where(WorkflowToolProvider.tenant_id == target_id))
|
|
assert provider.id != workspaces.provider_id
|
|
assert provider.name == _TOOL_NAME
|
|
tool_app = session.get(App, provider.app_id)
|
|
assert tool_app is not None
|
|
assert (tool_app.tenant_id, tool_app.name) == (target_id, "Summarizer")
|
|
published = session.get(Workflow, tool_app.workflow_id)
|
|
assert published is not None
|
|
assert published.version == provider.version
|
|
assert list(session.scalars(select(ToolLabelBinding.label_name).where(ToolLabelBinding.tool_id == provider.id)))
|
|
assert _caller_tool_node(session, str(result.app_id))["provider_id"] == provider.id
|
|
assert {app.id for app in session.scalars(select(App).where(App.tenant_id == target_id))} == {
|
|
tool_app.id,
|
|
result.app_id,
|
|
}
|
|
|
|
|
|
def test_import_into_the_same_workspace_reuses_the_existing_tool(
|
|
app_services: AppServices, workspaces: Workspaces, sqlite_session_factory: sessionmaker[Session]
|
|
) -> None:
|
|
result = _export_and_import(app_services, workspaces, workspaces.source, AppImportParams(mode="yaml-content"))
|
|
|
|
assert result.status == ImportStatus.COMPLETED
|
|
source_id = workspaces.source.active_workspace_id
|
|
with sqlite_session_factory() as session:
|
|
providers = session.scalars(select(WorkflowToolProvider.id).where(WorkflowToolProvider.tenant_id == source_id))
|
|
assert list(providers) == [workspaces.provider_id]
|
|
assert session.scalar(select(App.id).where(App.id == result.app_id)) is not None
|
|
assert len(list(session.scalars(select(App.id).where(App.tenant_id == source_id)))) == 3
|
|
assert _caller_tool_node(session, str(result.app_id))["provider_id"] == workspaces.provider_id
|
|
|
|
|
|
def test_import_renames_a_tool_whose_name_is_taken(
|
|
app_services: AppServices, workspaces: Workspaces, sqlite_session_factory: sessionmaker[Session]
|
|
) -> None:
|
|
target_id = workspaces.target.active_workspace_id
|
|
taken_app_id = str(uuid4())
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add_all(
|
|
[
|
|
App(
|
|
id=taken_app_id,
|
|
tenant_id=target_id,
|
|
name="Taken",
|
|
mode=AppMode.WORKFLOW,
|
|
enable_site=True,
|
|
enable_api=True,
|
|
),
|
|
_workflow_tool(
|
|
provider_id=str(uuid4()),
|
|
tenant_id=target_id,
|
|
app_id=taken_app_id,
|
|
version="1",
|
|
user_id=workspaces.target.account_id,
|
|
),
|
|
]
|
|
)
|
|
|
|
result = _export_and_import(app_services, workspaces, workspaces.target, AppImportParams(mode="yaml-content"))
|
|
|
|
assert result.status == ImportStatus.COMPLETED
|
|
with sqlite_session_factory() as session:
|
|
provider = session.scalar(
|
|
select(WorkflowToolProvider).where(
|
|
WorkflowToolProvider.tenant_id == target_id, WorkflowToolProvider.app_id != taken_app_id
|
|
)
|
|
)
|
|
assert provider is not None
|
|
assert provider.name == f"{_TOOL_NAME}_2"
|
|
node = _caller_tool_node(session, str(result.app_id))
|
|
assert (node["provider_id"], node["tool_name"]) == (provider.id, f"{_TOOL_NAME}_2")
|
|
|
|
|
|
def test_import_requires_permission_to_create_tools_before_creating_anything(
|
|
app_services: AppServices, workspaces: Workspaces, sqlite_session_factory: sessionmaker[Session]
|
|
) -> None:
|
|
target_id = workspaces.target.active_workspace_id
|
|
with sqlite_session_factory.begin() as session:
|
|
join = session.scalar(select(TenantAccountJoin).where(TenantAccountJoin.tenant_id == target_id))
|
|
assert join is not None
|
|
join.role = TenantAccountRole.EDITOR
|
|
|
|
with pytest.raises(NoPermissionError, match="workflow tools"):
|
|
_export_and_import(app_services, workspaces, workspaces.target, AppImportParams(mode="yaml-content"))
|
|
|
|
with sqlite_session_factory() as session:
|
|
assert list(session.scalars(select(App.id).where(App.tenant_id == target_id))) == []
|
|
|
|
|
|
@pytest.mark.parametrize("broker_available", [True, False])
|
|
def test_failed_import_deletes_the_workflows_it_created(
|
|
app_services: AppServices,
|
|
workspaces: Workspaces,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
broker_available: bool,
|
|
) -> None:
|
|
cleanups: list[dict[str, str]] = []
|
|
|
|
def enqueue_cleanup(**kwargs: str) -> None:
|
|
cleanups.append(kwargs)
|
|
if not broker_available:
|
|
raise ConnectionError("The message broker is unavailable")
|
|
|
|
monkeypatch.setattr(remove_app_and_related_data_task, "delay", enqueue_cleanup)
|
|
monkeypatch.setattr("services.app_service.app_was_deleted", Signal())
|
|
target_id = workspaces.target.active_workspace_id
|
|
|
|
result = _export_and_import(
|
|
app_services, workspaces, workspaces.target, AppImportParams(mode="yaml-content", app_id=str(uuid4()))
|
|
)
|
|
|
|
assert result.status == ImportStatus.FAILED
|
|
[cleanup] = cleanups
|
|
assert cleanup["tenant_id"] == target_id
|
|
assert ("left by a failed package import" in caplog.text) is not broker_available
|
|
with sqlite_session_factory() as session:
|
|
assert list(session.scalars(select(App.id).where(App.tenant_id == target_id))) == []
|
|
|
|
|
|
def test_import_recreates_workflows_that_call_each_other(
|
|
app_services: AppServices, workspaces: Workspaces, sqlite_session_factory: sessionmaker[Session]
|
|
) -> None:
|
|
_call_ranker_from_summarizer(sqlite_session_factory, workspaces, [_tool_node(workspaces.provider_id, _TOOL_NAME)])
|
|
|
|
result = _export_and_import(app_services, workspaces, workspaces.target, AppImportParams(mode="yaml-content"))
|
|
|
|
assert result.status == ImportStatus.COMPLETED
|
|
target_id = workspaces.target.active_workspace_id
|
|
with sqlite_session_factory() as session:
|
|
providers = {
|
|
provider.name: provider
|
|
for provider in session.scalars(
|
|
select(WorkflowToolProvider).where(WorkflowToolProvider.tenant_id == target_id)
|
|
)
|
|
}
|
|
assert set(providers) == {_TOOL_NAME, "rank"}
|
|
for name, called in ((_TOOL_NAME, "rank"), ("rank", _TOOL_NAME)):
|
|
published = session.scalar(
|
|
select(Workflow).where(
|
|
Workflow.app_id == providers[name].app_id, Workflow.version == providers[name].version
|
|
)
|
|
)
|
|
assert published is not None
|
|
[node] = published.graph_dict["nodes"]
|
|
assert node["data"]["provider_id"] == providers[called].id
|
|
|
|
|
|
def test_import_checks_the_quota_for_every_app_it_creates(
|
|
app_services: AppServices,
|
|
workspaces: Workspaces,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
config_overrides: Callable[..., None],
|
|
) -> None:
|
|
exported = app_services.console.export(workspaces.source, workspaces.caller_app_id, AppExportOptions())
|
|
assert not isinstance(exported, str)
|
|
config_overrides(DEPLOYMENT_EDITION="CLOUD")
|
|
# Billing limits come from the billing service; one App slot is left for a package that needs two.
|
|
monkeypatch.setattr(FeatureService, "get_features", _features_with_one_free_app)
|
|
|
|
with exported, pytest.raises(NoPermissionError, match="limit"):
|
|
app_services.console.import_app(
|
|
workspaces.target, AppImportParams(mode="yaml-content"), source=exported.archive
|
|
)
|
|
|
|
target_id = workspaces.target.active_workspace_id
|
|
with sqlite_session_factory() as session:
|
|
assert list(session.scalars(select(App.id).where(App.tenant_id == target_id))) == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("knowledge_node", "error", "created_apps"),
|
|
[
|
|
({}, "'rank' could not be imported", 1),
|
|
({"query_variable_selector": ["start", "query"]}, "'rank' could not be created", 2),
|
|
],
|
|
ids=["invalid-node", "missing-knowledge-base"],
|
|
)
|
|
def test_import_deletes_created_workflows_when_a_called_workflow_fails(
|
|
app_services: AppServices,
|
|
workspaces: Workspaces,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
knowledge_node: dict[str, object],
|
|
error: str,
|
|
created_apps: int,
|
|
) -> None:
|
|
knowledge: dict[str, object] = {
|
|
"id": "knowledge",
|
|
"data": {
|
|
"type": "knowledge-retrieval-v2",
|
|
"title": "Knowledge",
|
|
"control_space_ids": [str(uuid4())],
|
|
**knowledge_node,
|
|
},
|
|
}
|
|
_call_ranker_from_summarizer(sqlite_session_factory, workspaces, [knowledge])
|
|
cleanups: list[dict[str, str]] = []
|
|
monkeypatch.setattr(remove_app_and_related_data_task, "delay", lambda **kwargs: cleanups.append(kwargs))
|
|
monkeypatch.setattr("services.app_service.app_was_deleted", Signal())
|
|
|
|
with pytest.raises(InvalidRosterAgentPackageError, match=error):
|
|
_export_and_import(app_services, workspaces, workspaces.target, AppImportParams(mode="yaml-content"))
|
|
|
|
target_id = workspaces.target.active_workspace_id
|
|
assert len(cleanups) == created_apps
|
|
with sqlite_session_factory() as session:
|
|
assert list(session.scalars(select(App.id).where(App.tenant_id == target_id))) == []
|
|
|
|
|
|
def test_export_stops_at_the_workflow_tool_limit(
|
|
app_services: AppServices,
|
|
workspaces: Workspaces,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
config_overrides: Callable[..., None],
|
|
) -> None:
|
|
_call_ranker_from_summarizer(sqlite_session_factory, workspaces, [])
|
|
config_overrides(APP_PACKAGE_MAX_WORKFLOW_TOOLS=1)
|
|
|
|
with pytest.raises(RosterAgentPackageTooLargeError, match="too many workflow tools"):
|
|
app_services.console.export(workspaces.source, workspaces.caller_app_id, AppExportOptions())
|
|
|
|
|
|
def test_export_stops_once_the_workflow_tools_outgrow_the_package_limit(
|
|
app_services: AppServices,
|
|
workspaces: Workspaces,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
config_overrides: Callable[..., None],
|
|
) -> None:
|
|
_call_ranker_from_summarizer(sqlite_session_factory, workspaces, [])
|
|
with sqlite_session_factory.begin() as session:
|
|
for app in session.scalars(select(App).where(App.id != workspaces.caller_app_id)):
|
|
# Random data keeps each packaged workflow near its uncompressed size.
|
|
app.description = base64.b64encode(os.urandom(180_000)).decode()
|
|
# Each workflow fits on its own; the two together do not.
|
|
config_overrides(AGENT_PACKAGE_MAX_BYTES=300_000)
|
|
|
|
with pytest.raises(RosterAgentPackageTooLargeError, match="App package workflow tools exceed"):
|
|
app_services.console.export(workspaces.source, workspaces.caller_app_id, AppExportOptions())
|
|
|
|
|
|
@pytest.mark.parametrize("allowed", [True, False])
|
|
def test_planning_new_workflow_tools_checks_tool_management_under_rbac(
|
|
workspaces: Workspaces,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
rbac_domain: RBACDomain,
|
|
config_overrides: Callable[..., None],
|
|
allowed: bool,
|
|
) -> None:
|
|
config_overrides(RBAC_ENABLED=True)
|
|
rbac_domain.transport.response = {"allowed": allowed}
|
|
gateway = AppTransferGateway(
|
|
session_factory=sqlite_session_factory,
|
|
dsl_factory=AppDslService,
|
|
packages=AppPackageService(),
|
|
agent_packages=RosterAgentPackageExporter(),
|
|
agent_importer=RosterAgentPackageImporter(),
|
|
recommended_packages=RecommendedAppPackageService(
|
|
sources=DatabaseRecommendedAppCatalogRepository(session_factory=sqlite_session_factory, redis=redis_client),
|
|
exporter=RosterAgentPackageExporter(),
|
|
),
|
|
)
|
|
tool = PackagedWorkflowTool(
|
|
id="w_000001",
|
|
path="w_000001.ifpkg",
|
|
size=0,
|
|
sha256="0" * 64,
|
|
provider_id=workspaces.provider_id,
|
|
name=_TOOL_NAME,
|
|
label="Summarize",
|
|
icon={"background": "#FFEAD5", "content": "🤖"},
|
|
description="",
|
|
parameters=[],
|
|
labels=[],
|
|
privacy_policy="",
|
|
)
|
|
|
|
if allowed:
|
|
targets = gateway.plan_workflow_tools(workspaces.target, [tool], creates_root_app=True)
|
|
assert targets[workspaces.provider_id].create
|
|
else:
|
|
with pytest.raises(NoPermissionError, match="workflow tools"):
|
|
gateway.plan_workflow_tools(workspaces.target, [tool], creates_root_app=True)
|
|
request_json = rbac_domain.transport.only_request.json
|
|
assert request_json is not None
|
|
assert request_json["scene"] == RBACPermission.TOOL_MANAGE
|