Files
dify/api/tests/unit_tests/services/test_app_package_service.py
T

1348 lines
60 KiB
Python

from __future__ import annotations
import hashlib
import io
import json
import zipfile
from collections.abc import Callable, Generator, Mapping
from types import SimpleNamespace
from typing import cast
from unittest.mock import Mock
from uuid import uuid4
import pytest
import yaml
from sqlalchemy import Engine, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import QueuePool
from configs import dify_config
from constants.dsl_version import CURRENT_APP_DSL_VERSION
from libs.archive_writer import (
ArchiveLimits,
ArchiveOptions,
ArchiveResult,
StrictPathPolicy,
entries_from_folder,
write_archive,
)
from models import Account, App, AppMode
from models.agent import (
Agent,
AgentConfigSnapshot,
AgentScope,
AgentSource,
AgentStatus,
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import AgentSoulConfig
from models.model import UploadFile
from models.skill import AgentSkillBinding, AgentSkillBindingSnapshot, Skill, SkillVersion, SkillVersionManifest
from models.tools import ToolFile
from models.workflow import Workflow, WorkflowType
from services.agent.dsl_entities import AgentPackage
from services.agent.errors import (
InvalidRosterAgentPackageError,
RosterAgentPackageExportFailedError,
RosterAgentPackageTooLargeError,
)
from services.agent.package_resource_exporter import AgentPackageResourceExporter, _FileSource, _SkillSource
from services.agent.package_resource_importer import AgentPackageResourceImporter
from services.app_dsl_service import AppDslService, Import
from services.app_package_service import AppPackageManifest, AppPackageService
from services.dsl_content import DSL_MAX_SIZE
from services.entities.dsl_entities import ImportStatus
from services.errors.base import NoPermissionError
from services.plugin.dependencies_analysis import DependenciesAnalysisService
from tests.helpers.unit.model_factories import make_account, make_app, make_tenant, make_upload_file, make_workflow
"""Workflow packages restore node-owned Agents and assets across workspaces."""
def _bundle_any_app(app: App) -> bool:
del app
return True
class _MemoryStorage:
def __init__(self) -> None:
self.files: dict[str, bytes] = {}
self.saved: list[str] = []
def load_stream(self, filename: str) -> Generator[bytes]:
yield self.files[filename]
def save(self, filename: str, data: bytes) -> None:
self.files[filename] = data
self.saved.append(filename)
def _skill(name: str, marker: str) -> bytes:
output = io.BytesIO()
with zipfile.ZipFile(output, "w") as archive:
archive.writestr("SKILL.md", f"---\nname: {name}\ndescription: {marker}\n---\n\n# Instructions\n{marker}\n")
archive.writestr("scripts/run.py", f"print({marker!r})\n")
return output.getvalue()
def _tool_file(session: Session, storage: _MemoryStorage, key: str, payload: bytes) -> ToolFile:
row = ToolFile(
tenant_id="tenant-1",
user_id="account-1",
conversation_id=None,
file_key=key,
name=key.rsplit("/", 1)[-1],
mimetype="application/zip",
size=len(payload),
original_url=None,
)
storage.files[key] = payload
session.add(row)
return row
def _source(
session: Session, storage: _MemoryStorage, *, mode: AppMode = AppMode.WORKFLOW, published: bool = False
) -> App:
app = make_app(mode=mode, name="Inline Workflow")
nodes = [
{
"id": f"node-{index}",
"data": {"type": "agent", "version": "2", "title": f"Agent {index}", "agent_node_kind": "dify_agent"},
}
for index in (1, 2)
]
workflow = make_workflow(
workflow_id="workflow-1",
app_id=app.id,
graph={"nodes": nodes, "edges": []},
workflow_type=WorkflowType.CHAT if mode == AppMode.ADVANCED_CHAT else WorkflowType.WORKFLOW,
version="2026-01-01 00:00:00" if published else Workflow.VERSION_DRAFT,
)
session.add_all([app, workflow])
for index in (1, 2):
skill_file = _tool_file(
session, storage, f"tools/skill-{index}.zip", _skill("research", f"Agent {index} skill")
)
notes = _tool_file(session, storage, f"tools/notes-{index}.txt", f"Agent {index} notes".encode())
upload = make_upload_file(file_id=f"upload-{index}", key=f"uploads/guide-{index}.txt", name="guide.txt")
storage.files[upload.key] = f"Agent {index} guide".encode()
agent = Agent(
id=f"source-agent-{index}",
tenant_id=app.tenant_id,
name=f"Inline {index}",
scope=AgentScope.WORKFLOW_ONLY,
source=AgentSource.WORKFLOW,
app_id=app.id,
workflow_id=workflow.id,
workflow_node_id=f"node-{index}",
status=AgentStatus.ACTIVE,
created_by="account-1",
)
soul = AgentSoulConfig.model_validate(
{
"prompt": {"system_prompt": f"Bound snapshot {index}"},
"config_skills": [{"name": "research", "file_id": skill_file.id}],
"config_files": [
{"name": "notes.txt", "file_kind": "tool_file", "file_id": notes.id},
{"name": "guide.txt", "file_kind": "upload_file", "file_id": upload.id},
],
}
)
snapshot = AgentConfigSnapshot(
id=f"source-snapshot-{index}",
tenant_id=app.tenant_id,
agent_id=agent.id,
version=1,
config_snapshot=soul,
created_by="account-1",
)
active = AgentConfigSnapshot(
id=f"active-snapshot-{index}",
tenant_id=app.tenant_id,
agent_id=agent.id,
version=2,
config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "Unbound active snapshot"}}),
created_by="account-1",
)
agent.active_config_snapshot_id = active.id
binding = WorkflowAgentNodeBinding(
tenant_id=app.tenant_id,
app_id=app.id,
workflow_id=workflow.id,
workflow_version=workflow.version,
node_id=f"node-{index}",
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
agent_id=agent.id,
current_snapshot_id=snapshot.id,
node_job_config={"workflow_prompt": f"Job {index}"},
created_by="account-1",
)
session.add_all([upload, agent, snapshot, active, binding])
if index == 1:
workspace_archive = _tool_file(
session, storage, "tools/workspace.zip", _skill("workspace", "Workspace skill")
)
skill = Skill(
id="source-workspace-skill",
tenant_id=app.tenant_id,
name="workspace",
display_name="Workspace",
description="Workspace skill",
created_by="account-1",
updated_by="account-1",
)
version = SkillVersion(
id="source-skill-version",
skill_id=skill.id,
version_number=1,
version_name="1.0",
publish_note="",
manifest=SkillVersionManifest(
name="workspace", display_name="Workspace", description="Workspace skill", files=[]
),
archive_tool_file_id=workspace_archive.id,
archive_size=workspace_archive.size,
hash_code="workspace-hash",
published_by="account-1",
)
skill.latest_published_version_id = version.id
session.add_all(
[
skill,
version,
AgentSkillBindingSnapshot(
tenant_id=app.tenant_id,
agent_id=agent.id,
config_snapshot_id=snapshot.id,
skill_id=skill.id,
priority=0,
created_by="account-1",
),
]
)
session.commit()
return app
def _account(tenant_id: str = "tenant-2") -> Account:
return make_account(tenant=make_tenant(tenant_id=tenant_id))
def _import(
session: Session,
content: bytes,
*,
app_id: str | None = None,
account: Account | None = None,
preserve_agent_bindings: bool = False,
) -> Import:
prepared = AppPackageService().read_package(io.BytesIO(content), accept_roster_references=preserve_agent_bindings)
assert prepared is not None
with prepared:
result = AppDslService(session).import_app(
account=account or _account(),
import_mode="yaml-content",
yaml_content=prepared.dsl,
app_id=app_id,
package=prepared,
preserve_agent_bindings=preserve_agent_bindings,
)
if result.status == ImportStatus.FAILED:
session.rollback()
else:
session.commit()
return result
def _rewrite(
content: bytes, mutate: Callable[[AppPackageManifest, dict[str, object], dict[str, bytes]], None]
) -> bytes:
with zipfile.ZipFile(io.BytesIO(content)) as archive:
members = {name: archive.read(name) for name in archive.namelist()}
manifest = AppPackageManifest.model_validate(yaml.safe_load(members["manifest.yaml"]))
data = yaml.safe_load(members["app.yaml"])
mutate(manifest, data, members)
members["app.yaml"] = yaml.safe_dump(data).encode()
manifest.apps[0].size = len(members["app.yaml"])
manifest.apps[0].sha256 = hashlib.sha256(members["app.yaml"]).hexdigest()
members["manifest.yaml"] = yaml.safe_dump(manifest.model_dump(mode="json")).encode()
output = io.BytesIO()
with zipfile.ZipFile(output, "w") as archive:
for name, payload in members.items():
archive.writestr(name, payload)
return output.getvalue()
def _add_roster_node(session: Session, app: App, *, graph_binding: Mapping[str, object] | None = None) -> Agent:
workflow = session.get(Workflow, "workflow-1")
assert workflow is not None
graph = workflow.graph_dict
data: dict[str, object] = {"type": "agent", "version": "2", "title": "Roster", "agent_node_kind": "dify_agent"}
if graph_binding is not None:
data["agent_binding"] = dict(graph_binding)
graph["nodes"].append({"id": "node-3", "data": data})
workflow.graph = json.dumps(graph)
agent = Agent(
id="roster-agent-1",
tenant_id=app.tenant_id,
name="Roster",
scope=AgentScope.ROSTER,
source=AgentSource.ROSTER,
status=AgentStatus.ACTIVE,
active_config_snapshot_id="roster-snapshot-1",
created_by="account-1",
)
snapshot = AgentConfigSnapshot(
id="roster-snapshot-1",
tenant_id=app.tenant_id,
agent_id=agent.id,
version=1,
config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "Roster"}}),
created_by="account-1",
)
binding = WorkflowAgentNodeBinding(
tenant_id=app.tenant_id,
app_id=app.id,
workflow_id=workflow.id,
workflow_version=workflow.version,
node_id="node-3",
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
agent_id=agent.id,
current_snapshot_id=snapshot.id,
node_job_config={"workflow_prompt": "Roster job"},
created_by="account-1",
)
session.add_all([agent, snapshot, binding])
session.commit()
return agent
def _read_export(content: bytes) -> tuple[dict[str, object], dict[str, object]]:
with zipfile.ZipFile(io.BytesIO(content)) as archive:
return yaml.safe_load(archive.read("app.yaml")), yaml.safe_load(archive.read("manifest.yaml"))
def _node_data(data: dict[str, object], node_id: str = "node-3") -> dict[str, object]:
workflow = cast(dict[str, dict[str, list[dict[str, object]]]], data["workflow"])
return next(cast(dict[str, object], node["data"]) for node in workflow["graph"]["nodes"] if node["id"] == node_id)
def _roster_export(session: Session, storage: _MemoryStorage) -> bytes:
app = _source(session, storage)
_add_roster_node(session, app)
with AppPackageService().export_app(
may_bundle=_bundle_any_app, app_model=app, preserve_agent_bindings=True
) as exported:
return exported.archive.read()
def _bindings_by_node(session: Session, app_id: str | None) -> dict[str, WorkflowAgentNodeBinding]:
rows = session.scalars(select(WorkflowAgentNodeBinding).where(WorkflowAgentNodeBinding.app_id == app_id))
return {row.node_id: row for row in rows}
class TestWorkflowPackageServiceCases:
@staticmethod
@pytest.fixture
def storage(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> _MemoryStorage:
backend = _MemoryStorage()
monkeypatch.setattr("services.workflow_service.db", SimpleNamespace(engine=sqlite_engine))
monkeypatch.setattr(
"services.app_package_service.AgentPackageResourceExporter",
lambda: AgentPackageResourceExporter(storage_backend=backend),
)
monkeypatch.setattr(
"services.app_package_service.AgentPackageResourceImporter",
lambda: AgentPackageResourceImporter(storage_backend=backend),
)
monkeypatch.setattr(DependenciesAnalysisService, "generate_dependencies", lambda **_kwargs: [])
return backend
@staticmethod
@pytest.mark.parametrize("mode", [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
@pytest.mark.parametrize("published", [False, True])
def test_package_round_trip_restores_bound_agents_and_resources(
sqlite_session: Session,
storage: _MemoryStorage,
mode: AppMode,
published: bool,
) -> None:
app = _source(sqlite_session, storage, mode=mode, published=published)
with AppPackageService().export_app(
may_bundle=_bundle_any_app, app_model=app, workflow_id="workflow-1" if published else None
) as exported:
content = exported.archive.read()
with zipfile.ZipFile(io.BytesIO(content)) as archive:
manifest = yaml.safe_load(archive.read("manifest.yaml"))
assert all(
"audit" not in resource
for group in manifest["agent_resources"].values()
for resource in [*group["skills"], *group["files"]]
)
prepared = AppPackageService().read_package(io.BytesIO(content))
assert prepared is not None
with prepared:
assert len(prepared.agent_resources) == 2
assert [p.soul.prompt.system_prompt for p in prepared.agents.values()] == [
"Bound snapshot 1",
"Bound snapshot 2",
]
assert all(not p.omitted_assets for p in prepared.agents.values())
assert {r.name for group in prepared.agent_resources.values() for r in group.skills} == {
"research",
"workspace",
}
result = _import(sqlite_session, content)
assert result.status == ImportStatus.COMPLETED, result.error
assert result.app_mode == mode
bindings = sqlite_session.scalars(
select(WorkflowAgentNodeBinding)
.where(WorkflowAgentNodeBinding.app_id == result.app_id)
.order_by(WorkflowAgentNodeBinding.node_id)
).all()
assert len(bindings) == 2
for index, binding in enumerate(bindings, 1):
assert binding.tenant_id == "tenant-2"
assert binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT
assert binding.node_job_config_dict["workflow_prompt"] == f"Job {index}"
agent = sqlite_session.get(Agent, binding.agent_id)
assert agent is not None
assert (agent.scope, agent.app_id, agent.workflow_id, agent.workflow_node_id) == (
AgentScope.WORKFLOW_ONLY,
result.app_id,
binding.workflow_id,
binding.node_id,
)
snapshot = sqlite_session.get(AgentConfigSnapshot, binding.current_snapshot_id)
assert snapshot is not None
soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
assert soul.prompt.system_prompt == f"Bound snapshot {index}"
assert len(soul.config_skills) == (2 if index == 1 else 1)
for ref in soul.config_skills:
file = sqlite_session.get(ToolFile, ref.file_id)
assert file is not None
assert file.tenant_id == "tenant-2"
assert not ref.is_missing
with zipfile.ZipFile(io.BytesIO(storage.files[file.file_key])) as archive:
expected = f"Agent {index} skill" if ref.name == "research" else "Workspace skill"
assert expected.encode() in archive.read("SKILL.md")
for ref in soul.config_files:
row = sqlite_session.get(ToolFile if ref.file_kind == "tool_file" else UploadFile, ref.file_id)
assert row is not None
assert row.tenant_id == "tenant-2"
key = row.file_key if isinstance(row, ToolFile) else row.key
expected = f"Agent {index} notes" if ref.name == "notes.txt" else f"Agent {index} guide"
assert storage.files[key] == expected.encode()
if isinstance(row, UploadFile):
assert row.used is True
assert row.used_by == "account-1"
assert (
sqlite_session.scalar(select(AgentSkillBinding).where(AgentSkillBinding.agent_id == agent.id)) is None
)
@staticmethod
def test_yaml_export_still_omits_resources(sqlite_session: Session, storage: _MemoryStorage) -> None:
app = _source(sqlite_session, storage)
data = yaml.safe_load(AppDslService.export_dsl(app, session=sqlite_session))
for package in data["agent_packages"].values():
assert package["omitted_assets"]
assert all(item["is_missing"] and item["file_id"] == "" for item in package["soul"]["config_files"])
@staticmethod
def test_export_rejects_cross_tenant_assets(sqlite_session: Session, storage: _MemoryStorage) -> None:
app = _source(sqlite_session, storage)
row = sqlite_session.get(UploadFile, "upload-1")
assert row is not None
row.tenant_id = "tenant-elsewhere"
sqlite_session.commit()
with pytest.raises(RosterAgentPackageExportFailedError, match="payload is unavailable"):
AppPackageService().export_app(may_bundle=_bundle_any_app, app_model=app)
@staticmethod
@pytest.mark.parametrize(
"mutation", ["unknown_agent", "cross_agent_file", "unreferenced_agent", "duplicate_path", "file_checksum"]
)
def test_rejects_invalid_resource_ownership_before_upload(
sqlite_session: Session,
storage: _MemoryStorage,
mutation: str,
) -> None:
app = _source(sqlite_session, storage)
with AppPackageService().export_app(may_bundle=_bundle_any_app, app_model=app) as exported:
content = exported.archive.read()
def mutate(manifest: AppPackageManifest, data: dict[str, object], members: dict[str, bytes]) -> None:
resources = manifest.agent_resources
if mutation == "unknown_agent":
resources["unknown"] = resources.pop("agent_1")
elif mutation == "cross_agent_file":
agents = cast(dict[str, object], data["agent_packages"])
first = AgentPackage.model_validate(agents["agent_1"])
second = AgentPackage.model_validate(agents["agent_2"])
first.soul.config_files[0].file_id = second.soul.config_files[0].file_id
agents["agent_1"] = first.model_dump(mode="json")
elif mutation == "unreferenced_agent":
cast(dict[str, dict[str, list[object]]], data["workflow"])["graph"]["nodes"].pop()
elif mutation == "duplicate_path":
resources["agent_2"].files[0] = resources["agent_1"].files[0]
else:
resource = resources["agent_1"].files[0]
members[resource.path] = b"x" * resource.size
with pytest.raises(InvalidRosterAgentPackageError):
AppPackageService().read_package(io.BytesIO(_rewrite(content, mutate)))
assert storage.saved == []
@staticmethod
def test_pending_import_retains_materialized_resources_and_damage_warnings(
sqlite_session: Session,
storage: _MemoryStorage,
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = _source(sqlite_session, storage)
with AppPackageService().export_app(may_bundle=_bundle_any_app, app_model=app) as exported:
content = exported.archive.read()
def mutate(manifest: AppPackageManifest, data: dict[str, object], members: dict[str, bytes]) -> None:
resources = manifest.agent_resources
data["version"] = "99.0.0"
resource = resources["agent_1"].skills[0]
members[resource.path] = b"x" * resource.size
pending: dict[str, str] = {}
monkeypatch.setattr(
"services.app_dsl_service.redis_client.setex", lambda key, _ttl, value: pending.__setitem__(key, value)
)
monkeypatch.setattr("services.app_dsl_service.redis_client.get", pending.get)
monkeypatch.setattr("services.app_dsl_service.redis_client.delete", pending.pop)
result = _import(sqlite_session, _rewrite(content, mutate))
assert result.status == ImportStatus.PENDING
saved_count = len(storage.saved)
confirmed = AppDslService(sqlite_session).confirm_import(import_id=result.id, account=_account())
assert confirmed.status == ImportStatus.COMPLETED_WITH_WARNINGS, confirmed.error
assert any(
warning.code == "agent_skill_missing" and warning.path.startswith("agent_packages.agent_1.")
for warning in confirmed.warnings
)
assert len(storage.saved) == saved_count
assert not pending
@staticmethod
def test_overwrite_permission_is_checked_before_resource_upload(
sqlite_session: Session,
storage: _MemoryStorage,
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = _source(sqlite_session, storage)
with AppPackageService().export_app(may_bundle=_bundle_any_app, app_model=app) as exported:
content = exported.archive.read()
monkeypatch.setattr(AppDslService, "_load_app_for_overwrite", Mock(side_effect=NoPermissionError("Denied")))
with pytest.raises(NoPermissionError):
_import(sqlite_session, content, app_id="protected-app")
assert not storage.saved
@staticmethod
def test_overwrite_replaces_inline_agents_and_reports_previous_owners(
sqlite_session: Session,
storage: _MemoryStorage,
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = _source(sqlite_session, storage)
with AppPackageService().export_app(may_bundle=_bundle_any_app, app_model=app) as exported:
content = exported.archive.read()
original = _import(sqlite_session, content)
assert original.status == ImportStatus.COMPLETED, original.error
old_agents = set(
sqlite_session.scalars(
select(WorkflowAgentNodeBinding.agent_id).where(WorkflowAgentNodeBinding.app_id == original.app_id)
)
)
sqlite_session.commit()
retirement = Mock()
monkeypatch.setattr("services.app_dsl_service.WorkflowAgentRetirementService.retire_unowned", retirement)
updated = _import(sqlite_session, content, app_id=original.app_id)
assert updated.status == ImportStatus.COMPLETED, updated.error
assert updated.app_id == original.app_id
new_agents = set(
sqlite_session.scalars(
select(WorkflowAgentNodeBinding.agent_id).where(WorkflowAgentNodeBinding.app_id == original.app_id)
)
)
assert len(new_agents) == len(old_agents) == 2
assert not (new_agents & old_agents)
retirement.assert_called_once_with(tenant_id="tenant-2", agent_ids=old_agents, account_id="account-1")
@staticmethod
@pytest.mark.parametrize("legacy_skill", [False, True])
def test_resource_io_runs_without_database_transactions(
sqlite_session: Session,
storage: _MemoryStorage,
monkeypatch: pytest.MonkeyPatch,
legacy_skill: bool,
) -> None:
app = _source(sqlite_session, storage)
if legacy_skill:
version = sqlite_session.get(SkillVersion, "source-skill-version")
assert version is not None
version.manifest = version.manifest.model_copy(update={"name": None})
sqlite_session.commit()
engine = sqlite_session.get_bind()
assert isinstance(engine, Engine)
pool = engine.pool
assert isinstance(pool, QueuePool)
def before_io() -> None:
assert not sqlite_session.in_transaction()
assert pool.checkedout() == 0
def load_legacy_skill(*, tenant_id: str, file_id: str) -> bytes:
assert tenant_id == "tenant-1"
assert file_id
before_io()
return storage.files["tools/workspace.zip"]
monkeypatch.setattr(
"services.skill_management_service.SkillManagementService._load_tool_file_bytes",
staticmethod(load_legacy_skill),
)
load_stream = storage.load_stream
save = storage.save
def checked_load(filename: str) -> Generator[bytes]:
before_io()
yield from load_stream(filename)
def checked_save(filename: str, data: bytes) -> None:
before_io()
save(filename, data)
monkeypatch.setattr(storage, "load_stream", checked_load)
monkeypatch.setattr(storage, "save", checked_save)
with AppPackageService().export_app(may_bundle=_bundle_any_app, app_model=app) as exported:
content = exported.archive.read()
result = _import(sqlite_session, content)
assert result.status == ImportStatus.COMPLETED, result.error
@staticmethod
def test_missing_assets_remain_explicit_import_warnings(sqlite_session: Session, storage: _MemoryStorage) -> None:
app = _source(sqlite_session, storage)
snapshot = sqlite_session.get(AgentConfigSnapshot, "source-snapshot-1")
assert snapshot is not None
soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
soul.config_files[0].is_missing = True
soul.config_files[0].file_id = ""
snapshot.config_snapshot = soul
sqlite_session.commit()
with AppPackageService().export_app(may_bundle=_bundle_any_app, app_model=app) as exported:
result = _import(sqlite_session, exported.archive.read())
assert result.status == ImportStatus.COMPLETED_WITH_WARNINGS, result.error
assert any(warning.code == "agent_file_omitted" for warning in result.warnings)
@staticmethod
def test_reader_keeps_legacy_packaged_roster_nodes(sqlite_session: Session, storage: _MemoryStorage) -> None:
app = _source(sqlite_session, storage)
with AppPackageService().export_app(may_bundle=_bundle_any_app, app_model=app) as exported:
content = exported.archive.read()
def mutate(_manifest: AppPackageManifest, data: dict[str, object], _members: dict[str, bytes]) -> None:
cast(dict[str, object], _node_data(data, "node-1")["agent_binding"])["binding_type"] = "roster_agent"
legacy = _rewrite(content, mutate)
prepared = AppPackageService().read_package(io.BytesIO(legacy), accept_roster_references=True)
assert prepared is not None
prepared.close()
@staticmethod
def test_package_export_keeps_roster_references(sqlite_session: Session, storage: _MemoryStorage) -> None:
app = _source(sqlite_session, storage)
_add_roster_node(sqlite_session, app)
with AppPackageService().export_app(
may_bundle=_bundle_any_app, app_model=app, preserve_agent_bindings=True
) as exported:
data, manifest = _read_export(exported.archive.read())
node = _node_data(data)
assert node["agent_binding"] == {
"binding_type": "roster_agent",
"agent_id": "roster-agent-1",
"current_snapshot_id": "roster-snapshot-1",
}
assert node["agent_task"] == "Roster job"
assert (
set(cast(dict[str, object], manifest["agent_resources"]))
== set(cast(dict[str, object], data["agent_packages"]))
== {"agent_1", "agent_2"}
)
@staticmethod
@pytest.mark.usefixtures("storage")
def test_roster_only_package_export_passes_self_check(sqlite_session: Session) -> None:
app = make_app(mode=AppMode.WORKFLOW, name="Roster Only")
workflow = make_workflow(
workflow_id="workflow-1",
app_id=app.id,
graph={"nodes": [], "edges": []},
workflow_type=WorkflowType.WORKFLOW,
version=Workflow.VERSION_DRAFT,
)
sqlite_session.add_all([app, workflow])
sqlite_session.commit()
_add_roster_node(sqlite_session, app)
with AppPackageService().export_app(
may_bundle=_bundle_any_app, app_model=app, preserve_agent_bindings=True
) as exported:
data, manifest = _read_export(exported.archive.read())
assert not manifest.get("agent_resources")
assert cast(dict[str, object], _node_data(data)["agent_binding"])["agent_id"] == "roster-agent-1"
@staticmethod
def test_published_export_projects_the_exported_versions_bindings(
sqlite_session: Session, storage: _MemoryStorage
) -> None:
app = _source(sqlite_session, storage, published=True)
_add_roster_node(sqlite_session, app, graph_binding={"binding_type": "roster_agent", "agent_id": "stale-agent"})
with AppPackageService().export_app(
may_bundle=_bundle_any_app, app_model=app, workflow_id="workflow-1", preserve_agent_bindings=True
) as exported:
content = exported.archive.read()
data, _manifest = _read_export(content)
node = _node_data(data)
assert cast(dict[str, object], node["agent_binding"])["agent_id"] == "roster-agent-1"
assert node["agent_task"] == "Roster job"
result = _import(sqlite_session, content, account=_account("tenant-1"), preserve_agent_bindings=True)
assert result.status == ImportStatus.COMPLETED, result.error
roster = sqlite_session.scalars(
select(WorkflowAgentNodeBinding).where(
WorkflowAgentNodeBinding.app_id == result.app_id,
WorkflowAgentNodeBinding.node_id == "node-3",
)
).one()
assert (roster.binding_type, roster.agent_id) == (WorkflowAgentBindingType.ROSTER_AGENT, "roster-agent-1")
assert roster.node_job_config_dict["workflow_prompt"] == "Roster job"
@staticmethod
def test_overwrite_with_mixed_graph_keeps_roster_link(
sqlite_session: Session, storage: _MemoryStorage, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
"services.app_dsl_service.WorkflowAgentRetirementService.retire_unowned", lambda **_kwargs: None
)
content = _roster_export(sqlite_session, storage)
account = _account("tenant-1")
original = _import(sqlite_session, content, account=account, preserve_agent_bindings=True)
updated = _import(
sqlite_session, content, app_id=original.app_id, account=account, preserve_agent_bindings=True
)
assert updated.status == ImportStatus.COMPLETED, updated.error
rows = _bindings_by_node(sqlite_session, original.app_id)
assert rows["node-3"].binding_type == WorkflowAgentBindingType.ROSTER_AGENT
assert rows["node-3"].agent_id == "roster-agent-1"
assert rows["node-3"].node_job_config_dict["workflow_prompt"] == "Roster job"
assert {rows["node-1"].binding_type, rows["node-2"].binding_type} == {WorkflowAgentBindingType.INLINE_AGENT}
@staticmethod
def test_unresolvable_roster_reference_fails_the_import(sqlite_session: Session, storage: _MemoryStorage) -> None:
content = _roster_export(sqlite_session, storage)
def mutate(_manifest: AppPackageManifest, data: dict[str, object], _members: dict[str, bytes]) -> None:
cast(dict[str, object], _node_data(data)["agent_binding"])["agent_id"] = "missing-agent"
result = _import(
sqlite_session, _rewrite(content, mutate), account=_account("tenant-1"), preserve_agent_bindings=True
)
assert result.status == ImportStatus.FAILED
assert "roster agent" in (result.error or "")
@staticmethod
def test_confirm_keeps_roster_link(
sqlite_session: Session, storage: _MemoryStorage, monkeypatch: pytest.MonkeyPatch
) -> None:
content = _roster_export(sqlite_session, storage)
def mutate(_manifest: AppPackageManifest, data: dict[str, object], _members: dict[str, bytes]) -> None:
data["version"] = "99.0.0"
pending: dict[str, str] = {}
monkeypatch.setattr(
"services.app_dsl_service.redis_client.setex", lambda key, _ttl, value: pending.__setitem__(key, value)
)
monkeypatch.setattr("services.app_dsl_service.redis_client.get", pending.get)
monkeypatch.setattr("services.app_dsl_service.redis_client.delete", pending.pop)
account = _account("tenant-1")
result = _import(sqlite_session, _rewrite(content, mutate), account=account, preserve_agent_bindings=True)
assert result.status == ImportStatus.PENDING
confirmed = AppDslService(sqlite_session).confirm_import(import_id=result.id, account=account)
assert confirmed.status in {ImportStatus.COMPLETED, ImportStatus.COMPLETED_WITH_WARNINGS}, confirmed.error
row = _bindings_by_node(sqlite_session, confirmed.app_id)["node-3"]
assert row.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
assert row.agent_id == "roster-agent-1"
"""Ordinary App packages preserve DSLs and enforce container integrity."""
def _archive(dsl: str, **manifest_overrides: object) -> io.BytesIO:
payload = dsl.encode()
manifest = {
"format": "dify.app",
"format_version": 1,
"apps": [{"path": "app.yaml", "size": len(payload), "sha256": hashlib.sha256(payload).hexdigest()}],
**manifest_overrides,
}
source = io.BytesIO()
with zipfile.ZipFile(source, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr("manifest.yaml", yaml.safe_dump(manifest))
archive.writestr("app.yaml", payload)
source.seek(0)
return source
def _dsl_with_size(size: int) -> str:
header = "kind: app\napp:\n mode: workflow\n description: "
padding = size - len(header.encode("utf-8"))
return header + "\U0001f600" * (padding // 4) + "a" * (padding % 4)
def _roster_archive(binding: dict[str, object], agent_job: dict[str, object] | None = None) -> io.BytesIO:
node_data = {
"type": "agent",
"version": "2",
"title": "Roster",
"agent_node_kind": "dify_agent",
"agent_binding": binding,
"agent_job": agent_job or {"workflow_prompt": "Roster job"},
}
dsl = yaml.safe_dump(
{
"kind": "app",
"app": {"mode": "workflow", "name": "Roster"},
"workflow": {"graph": {"nodes": [{"id": "node-1", "data": node_data}], "edges": []}},
}
)
with AppPackageService().export(dsl=dsl, name="Roster", workflow_tools=[]) as package:
return io.BytesIO(package.archive.read())
_FOLDER_DSL = "kind: app\nversion: 0.1.0\napp:\n mode: workflow\n"
def _rebuild_from_folder(extra: dict[str, bytes]) -> ArchiveResult:
with (
AppPackageService().export(dsl=_FOLDER_DSL, name="Example", workflow_tools=[]) as package,
zipfile.ZipFile(package.archive) as source,
):
parts = {name: io.BytesIO(source.read(name)) for name in source.namelist()}
parts.update({path: io.BytesIO(data) for path, data in extra.items()})
limits = ArchiveLimits(dify_config.AGENT_PACKAGE_MAX_ENTRIES, dify_config.AGENT_PACKAGE_MAX_BYTES)
return write_archive(entries_from_folder(parts), ArchiveOptions(StrictPathPolicy(), limits))
class TestAppPackageServiceCases:
@staticmethod
@pytest.mark.parametrize("mode", ["workflow", "advanced-chat", "chat", "completion", "agent-chat"])
@pytest.mark.parametrize("version", ["0.1.0", "99.0.0"])
def test_round_trip_retains_exact_dsl_and_defers_version_handling(mode: str, version: str) -> None:
dsl = f"kind: app\nversion: {version}\napp:\n mode: {mode}\n# Preserve the DSL verbatim.\n"
service = AppPackageService()
with service.export(dsl=dsl, name="Example App", workflow_tools=[]) as package:
assert package.filename == "example-app.ifpkg"
_, manifest = _read_export(package.archive.read())
assert manifest["format_version"] == 2
package.archive.seek(0)
prepared = service.read_package(package.archive)
assert prepared is not None
with prepared:
assert prepared.dsl == dsl
assert package.archive.tell() == 0
assert package.archive.closed
@staticmethod
@pytest.mark.parametrize("format_version", [1, 2])
def test_reads_packages_without_workflow_tools(format_version: int) -> None:
prepared = AppPackageService().read_package(_archive(_FOLDER_DSL, format_version=format_version))
assert prepared is not None
with prepared:
assert prepared.dsl == _FOLDER_DSL
assert prepared.workflow_tools == []
@staticmethod
@pytest.mark.parametrize("overrides", [{"format": "unknown"}, {"format_version": 3}, {"apps": []}])
def test_rejects_unsupported_manifests(overrides: dict[str, object]) -> None:
with pytest.raises(InvalidRosterAgentPackageError, match="manifest"):
AppPackageService().read_package(_archive("kind: app\napp: {mode: workflow}\n", **overrides))
@staticmethod
@pytest.mark.parametrize(
"dsl",
[
"[]",
"kind: app\napp: {mode: agent}",
"kind: app\napp: {mode: unknown}",
"kind: app\napp: {mode: workflow, mode: chat}",
"kind: app\napp: {mode: workflow}\nsite: not-a-map",
],
)
def test_rejects_invalid_or_agent_dsl_in_ordinary_container(dsl: str) -> None:
with pytest.raises(InvalidRosterAgentPackageError):
AppPackageService().read_package(_archive(dsl))
@staticmethod
def test_rejects_checksum_mismatch() -> None:
dsl = "kind: app\napp: {mode: workflow}\n"
source = _archive(dsl, apps=[{"path": "app.yaml", "size": len(dsl), "sha256": "0" * 64}])
with pytest.raises(InvalidRosterAgentPackageError, match="integrity"):
AppPackageService().read_package(source)
@staticmethod
@pytest.mark.parametrize("path", ["extra.txt", "../escape", "APP.YAML"])
def test_rejects_unlisted_unsafe_and_duplicate_members(path: str) -> None:
source = _archive("kind: app\napp: {mode: workflow}\n")
with zipfile.ZipFile(source, "a") as archive:
archive.writestr(path, "unexpected")
source.seek(0)
with pytest.raises(InvalidRosterAgentPackageError):
AppPackageService().read_package(source)
@staticmethod
def test_rejects_oversized_container(config_overrides: Callable[..., None]) -> None:
source = _archive("kind: app\napp: {mode: workflow}\n")
config_overrides(AGENT_PACKAGE_MAX_BYTES=10)
with pytest.raises(RosterAgentPackageTooLargeError):
AppPackageService().read_package(source)
@staticmethod
@pytest.mark.parametrize("size", [6 * 1024 * 1024, DSL_MAX_SIZE])
def test_package_round_trip_accepts_large_dsl_within_legacy_limit(size: int) -> None:
dsl = _dsl_with_size(size)
service = AppPackageService()
with service.export(dsl=dsl, name="Large Workflow", workflow_tools=[]) as package:
prepared = service.read_package(package.archive)
assert prepared is not None
with prepared:
assert prepared.dsl == dsl
@staticmethod
def test_export_rejects_dsl_exceeding_legacy_byte_limit() -> None:
dsl = _dsl_with_size(DSL_MAX_SIZE + 1)
with pytest.raises(RosterAgentPackageTooLargeError):
AppPackageService().export(dsl=dsl, name="Large Workflow", workflow_tools=[])
@staticmethod
def test_import_rejects_compressed_dsl_exceeding_legacy_byte_limit() -> None:
dsl = _dsl_with_size(DSL_MAX_SIZE + 1)
source = _archive(dsl)
with pytest.raises(RosterAgentPackageTooLargeError):
AppPackageService().read_package(source)
@staticmethod
@pytest.mark.parametrize(
("binding", "agent_job"),
[
pytest.param({"binding_type": "roster_agent"}, None, id="agent-id-missing"),
pytest.param({"binding_type": "roster_agent", "agent_id": ""}, None, id="agent-id-empty"),
pytest.param(
{"binding_type": "roster_agent", "agent_id": "a", "package_ref": "agent_1"},
None,
id="package-ref-extra",
),
pytest.param(
{"binding_type": "roster_agent", "agent_id": "a"},
{"declared_outputs": "not-a-list"},
id="invalid-agent-job",
),
],
)
def test_roster_only_package_rejects_bad_references_when_accepted(
binding: dict[str, object], agent_job: dict[str, object] | None
) -> None:
with pytest.raises(InvalidRosterAgentPackageError, match="Agent references are invalid"):
AppPackageService().read_package(_roster_archive(binding, agent_job), accept_roster_references=True)
@staticmethod
def test_non_workflow_package_is_accepted_when_roster_references_accepted() -> None:
dsl = yaml.safe_dump({"kind": "app", "app": {"mode": "chat", "name": "Chat"}})
with AppPackageService().export(dsl=dsl, name="Chat", workflow_tools=[]) as package:
prepared = AppPackageService().read_package(package.archive, accept_roster_references=True)
assert prepared is not None
assert prepared.agents == {}
prepared.close()
@staticmethod
def test_an_unpacked_export_rebuilt_from_folder_parts_reads_back_and_nesting_is_refused() -> None:
with _rebuild_from_folder({".DS_Store": b""}).file as archive:
prepared = AppPackageService().read_package(archive)
assert prepared is not None
with prepared:
assert prepared.dsl == _FOLDER_DSL
with _rebuild_from_folder({"skills/a/SKILL.md": b""}).file as archive:
with pytest.raises(InvalidRosterAgentPackageError, match="unsafe path"):
AppPackageService().read_package(archive)
"""Packages bundle the workflows an App calls through Workflow Tools as nested App packages."""
_WORKFLOW_DSL = f"kind: app\nversion: {CURRENT_APP_DSL_VERSION}\napp:\n mode: workflow\n"
def _nested_package(dsl: str) -> bytes:
with AppPackageService().export(dsl=dsl, name="Summarize", workflow_tools=[]) as exported:
return exported.archive.read()
def _workflow_tool(index: int, member: bytes, **overrides: object) -> dict[str, object]:
return {
"id": f"w_{index:06d}",
"path": f"w_{index:06d}.ifpkg",
"size": len(member),
"sha256": hashlib.sha256(member).hexdigest(),
"provider_id": f"33333333-3333-4333-8333-{index:012d}",
"name": f"summarize_{index}",
"label": "Summarize",
"icon": {"background": "#FFEAD5", "content": "🤖"},
"description": "Summarize a document",
"parameters": [{"name": "document", "description": "Document", "form": "llm"}],
"labels": ["productivity"],
"privacy_policy": "",
**overrides,
}
def _bundle(
members: list[bytes], *, root_dsl: str = _WORKFLOW_DSL, format_version: int = 2, **tool_overrides: object
) -> io.BytesIO:
tools = [_workflow_tool(index, member, **tool_overrides) for index, member in enumerate(members, start=1)]
source = _archive(root_dsl, format_version=format_version, workflow_tools=tools)
with zipfile.ZipFile(source, "a") as archive:
for tool, member in zip(tools, members, strict=True):
archive.writestr(str(tool["path"]), member)
source.seek(0)
return source
class TestWorkflowToolPackageCases:
@staticmethod
def test_reads_bundled_workflow_tools_and_opens_each_as_an_app_package() -> None:
member = _nested_package(_WORKFLOW_DSL)
prepared = AppPackageService().read_package(_bundle([member]))
assert prepared is not None
with prepared:
assert prepared.dsl == _WORKFLOW_DSL
[tool] = prepared.workflow_tools
assert (tool.provider_id, tool.name, tool.labels) == (
"33333333-3333-4333-8333-000000000001",
"summarize_1",
["productivity"],
)
with prepared.open_workflow_tool(tool) as nested:
assert nested.dsl == _WORKFLOW_DSL
assert nested.workflow_tools == []
@staticmethod
@pytest.mark.parametrize(
("format_version", "tool_overrides"),
[
(1, {}),
(2, {"name": "not a tool name"}),
(2, {"provider_id": "not-a-uuid"}),
(2, {"path": "w_000009.ifpkg"}),
],
ids=["v1-with-tools", "invalid-name", "invalid-provider-id", "path-without-its-id"],
)
def test_rejects_invalid_workflow_tool_manifests(format_version: int, tool_overrides: dict[str, str]) -> None:
member = _nested_package(_WORKFLOW_DSL)
with pytest.raises(InvalidRosterAgentPackageError, match="manifest"):
AppPackageService().read_package(_bundle([member], format_version=format_version, **tool_overrides))
@staticmethod
def test_rejects_workflow_tool_checksum_mismatch() -> None:
member = _nested_package(_WORKFLOW_DSL)
with pytest.raises(InvalidRosterAgentPackageError, match="integrity"):
AppPackageService().read_package(_bundle([member], sha256="0" * 64))
@staticmethod
@pytest.mark.parametrize(
"nested_dsl",
[
f"kind: app\nversion: {CURRENT_APP_DSL_VERSION}\napp:\n mode: advanced-chat\n",
"kind: app\nversion: 99.0.0\napp:\n mode: workflow\n",
],
ids=["not-a-workflow", "needs-version-confirmation"],
)
def test_rejects_workflow_tools_that_cannot_import_with_the_package(nested_dsl: str) -> None:
with pytest.raises(InvalidRosterAgentPackageError):
AppPackageService().read_package(_bundle([_nested_package(nested_dsl)]))
@staticmethod
def test_rejects_a_root_app_that_needs_version_confirmation_with_workflow_tools() -> None:
member = _nested_package(_WORKFLOW_DSL)
root_dsl = "kind: app\nversion: 99.0.0\napp:\n mode: workflow\n"
with pytest.raises(InvalidRosterAgentPackageError, match="confirmation"):
AppPackageService().read_package(_bundle([member], root_dsl=root_dsl))
@staticmethod
def test_rejects_workflow_tools_that_bundle_workflow_tools() -> None:
nested = _bundle([_nested_package(_WORKFLOW_DSL)]).getvalue()
with pytest.raises(InvalidRosterAgentPackageError, match="must not bundle"):
AppPackageService().read_package(_bundle([nested]))
@staticmethod
def test_rejects_more_workflow_tools_than_the_limit(config_overrides: Callable[..., None]) -> None:
member = _nested_package(_WORKFLOW_DSL)
config_overrides(APP_PACKAGE_MAX_WORKFLOW_TOOLS=1)
with pytest.raises(RosterAgentPackageTooLargeError, match="too many workflow tools"):
AppPackageService().read_package(_bundle([member, member]))
@staticmethod
def test_rejects_workflow_tools_that_share_a_provider_id() -> None:
member = _nested_package(_WORKFLOW_DSL)
with pytest.raises(InvalidRosterAgentPackageError, match="manifest"):
AppPackageService().read_package(
_bundle([member, member], provider_id="33333333-3333-4333-8333-000000000009")
)
@staticmethod
def test_rejects_a_workflow_tool_larger_than_declared() -> None:
member = _nested_package(_WORKFLOW_DSL)
with pytest.raises(InvalidRosterAgentPackageError, match="integrity"):
AppPackageService().read_package(_bundle([member], size=1))
@staticmethod
def test_rejects_a_workflow_tool_that_is_an_agent_package() -> None:
member = io.BytesIO()
with zipfile.ZipFile(member, "w") as archive:
archive.writestr("manifest.yaml", yaml.safe_dump({"format": "dify.roster-agent", "format_version": 1}))
with pytest.raises(InvalidRosterAgentPackageError, match="must be an App package"):
AppPackageService().read_package(_bundle([member.getvalue()]))
@staticmethod
def test_rejects_a_corrupted_workflow_tool_member() -> None:
source = _bundle([_nested_package(_WORKFLOW_DSL)])
with zipfile.ZipFile(source) as archive:
offset = archive.getinfo("w_000001.ifpkg").header_offset
payload = bytearray(source.getvalue())
# A local file header is 30 bytes, then the member name and extra field, then the stored data.
name_length = int.from_bytes(payload[offset + 26 : offset + 28], "little")
extra_length = int.from_bytes(payload[offset + 28 : offset + 30], "little")
payload[offset + 30 + name_length + extra_length] ^= 0xFF
with pytest.raises(InvalidRosterAgentPackageError, match="not a valid ZIP"):
AppPackageService().read_package(io.BytesIO(bytes(payload)))
@staticmethod
def test_bounds_what_all_workflow_tools_expand_to(config_overrides: Callable[..., None]) -> None:
dsl = f"{_WORKFLOW_DSL} description: {'a' * 60_000}\n"
member = _nested_package(dsl)
config_overrides(AGENT_PACKAGE_MAX_BYTES=100_000)
with pytest.raises(RosterAgentPackageTooLargeError, match="workflow tools exceed"):
AppPackageService().read_package(_bundle([member, member]))
@staticmethod
def test_the_app_and_its_workflow_tools_share_one_size_limit(config_overrides: Callable[..., None]) -> None:
member = _nested_package(f"{_WORKFLOW_DSL} description: {'a' * 60_000}\n")
root_dsl = f"{_WORKFLOW_DSL} description: {'b' * 50_000}\n"
config_overrides(AGENT_PACKAGE_MAX_BYTES=100_000)
with pytest.raises(RosterAgentPackageTooLargeError, match="App package and its workflow tools exceed"):
AppPackageService().read_package(_bundle([member], root_dsl=root_dsl))
"""Packaged image icons travel with Apps and receive destination-owned file ids."""
def _icon_archive(*, payload: bytes = b"image", digest: str | None = None, reference: str = "i_000001") -> io.BytesIO:
dsl = yaml.safe_dump({"kind": "app", "app": {"mode": "chat", "icon_type": "image", "icon": reference}})
with AppPackageService().export(
dsl=dsl.replace(reference, "legacy-id"), name="Example", workflow_tools=[]
) as exported:
with zipfile.ZipFile(exported.archive) as package:
manifest = yaml.safe_load(package.read("manifest.yaml"))
app_bytes = dsl.encode()
manifest["apps"][0].update(size=len(app_bytes), sha256=hashlib.sha256(app_bytes).hexdigest())
manifest["icons"] = [
{
"id": "i_000001",
"path": "i_000001.png",
"size": len(payload),
"sha256": digest or hashlib.sha256(payload).hexdigest(),
}
]
output = io.BytesIO()
with zipfile.ZipFile(output, "w") as package:
package.writestr("manifest.yaml", yaml.safe_dump(manifest))
package.writestr("app.yaml", app_bytes)
package.writestr("i_000001.png", payload)
output.seek(0)
return output
class TestPackageIconsCases:
@staticmethod
def test_app_icon_export_deduplicates_uploads_and_restores_references(
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
source_id = str(uuid4())
payload = b"image payload"
backend = _MemoryStorage()
backend.files["source/icon.png"] = payload
source = make_upload_file(
file_id=source_id,
tenant_id="source-tenant",
key="source/icon.png",
name="icon.png",
size=len(payload),
extension="png",
mime_type="image/png",
)
sqlite_session.add(source)
sqlite_session.commit()
exporter = AgentPackageResourceExporter(storage_backend=backend)
monkeypatch.setattr(
"services.app_package_service.AgentPackageResourceImporter",
lambda: AgentPackageResourceImporter(storage_backend=backend),
)
metadata = {"mode": "chat", "icon_type": "image", "icon": source_id}
duplicate = dict(metadata)
exporter.collect_icon(session=sqlite_session, tenant_id="source-tenant", metadata=metadata)
exporter.collect_icon(session=sqlite_session, tenant_id="source-tenant", metadata=duplicate)
assert metadata["icon"] == duplicate["icon"] == "i_000001"
dsl = yaml.safe_dump({"kind": "app", "app": metadata})
with AppPackageService().export(dsl=dsl, name="Example", resources=exporter, workflow_tools=[]) as exported:
with zipfile.ZipFile(exported.archive) as package:
assert package.read("i_000001.png") == payload
exported.archive.seek(0)
prepared = AppPackageService().read_package(exported.archive)
assert prepared is not None
with prepared:
assert len(prepared.icons) == 1
data = yaml.safe_load(prepared.dsl)
prepared.materialize_icons(data=data, tenant_id="destination", account_id="account")
destination_id = data["app"]["icon"]
assert destination_id != source_id
restored = sqlite_session.get(UploadFile, destination_id)
assert restored is not None
assert restored.tenant_id == "destination"
assert restored.created_by == "account"
assert restored.mime_type == "image/png"
assert restored.size == len(payload)
assert backend.files[restored.key] == payload
assert sqlite_session.scalars(
select(UploadFile).where(UploadFile.tenant_id == "destination")
).all() == [restored]
assert source.tenant_id == "source-tenant"
assert backend.files[source.key] == payload
@staticmethod
def test_app_package_restores_independent_site_image_icon(
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
app_icon_id, site_icon_id = str(uuid4()), str(uuid4())
backend = Mock()
backend.load_stream.side_effect = lambda key: iter([key.encode()])
exporter = AgentPackageResourceExporter(storage_backend=backend)
monkeypatch.setattr(
exporter,
"_upload_files",
Mock(
side_effect=lambda *, file_ids, **_kwargs: {
file_id: SimpleNamespace(key=f"source/{file_id}.png", extension="png") for file_id in file_ids
}
),
)
app = {"mode": "chat", "icon_type": "image", "icon": app_icon_id}
site = {"title": "Site", "icon_type": "image", "icon": site_icon_id}
exporter.collect_icon(session=unbound_session, tenant_id="source", metadata=app)
exporter.collect_icon(session=unbound_session, tenant_id="source", metadata=site)
with AppPackageService().export(
dsl=yaml.safe_dump({"kind": "app", "app": app, "site": site}),
name="App",
resources=exporter,
workflow_tools=[],
) as exported:
prepared = AppPackageService().read_package(exported.archive)
assert prepared is not None
with prepared:
assert len(prepared.icons) == 2
monkeypatch.setattr(
AgentPackageResourceImporter,
"materialize_icons",
Mock(return_value={app["icon"]: "new-app-icon", site["icon"]: "new-site-icon"}),
)
data = yaml.safe_load(prepared.dsl)
prepared.materialize_icons(data=data, tenant_id="destination", account_id="account")
assert data["app"]["icon"] == "new-app-icon"
assert data["site"]["icon"] == "new-site-icon"
@staticmethod
def test_icon_export_rejects_unavailable_tenant_upload(
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
exporter = AgentPackageResourceExporter(storage_backend=Mock())
monkeypatch.setattr(exporter, "_upload_files", Mock(return_value={}))
with pytest.raises(RosterAgentPackageExportFailedError, match="unavailable"):
exporter.collect_icon(
session=unbound_session,
tenant_id="tenant",
metadata={"icon_type": "image", "icon": str(uuid4())},
)
@staticmethod
def test_icon_checksum_is_required() -> None:
with pytest.raises(InvalidRosterAgentPackageError, match="integrity"):
AppPackageService().read_package(_icon_archive(digest="0" * 64))
@staticmethod
@pytest.mark.parametrize("reference", ["i_000002", "unrelated-upload"])
def test_icon_must_resolve_to_metadata(reference: str) -> None:
with pytest.raises(InvalidRosterAgentPackageError, match="references"):
AppPackageService().read_package(_icon_archive(reference=reference))
@staticmethod
def test_icon_image_limit_is_enforced(config_overrides: Callable[..., None]) -> None:
config_overrides(UPLOAD_IMAGE_FILE_SIZE_LIMIT=0)
with pytest.raises(RosterAgentPackageTooLargeError, match="image size"):
AppPackageService().read_package(_icon_archive())
@staticmethod
def test_roster_export_embeds_agent_icon(monkeypatch: pytest.MonkeyPatch, unbound_session: Session) -> None:
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode
from services.agent.dsl_entities import AgentPackage, AgentPackageMetadata, make_agent_app_dsl
from services.agent.roster_package_exporter import RosterAgentPackageExporter
from services.agent.roster_package_reader import RosterAgentPackageReader
from services.entities.site_dsl import SiteDsl
backend = Mock()
backend.load_stream.side_effect = lambda _: iter([b"icon"])
resources = AgentPackageResourceExporter(storage_backend=backend)
resources.sources["agent_1"] = (list[_SkillSource](), list[_FileSource]())
metadata = {"name": "Agent", "icon_type": "image", "icon": "source-id"}
monkeypatch.setattr(
resources,
"_upload_files",
Mock(
return_value={
"source-id": SimpleNamespace(key="icon.png", extension="png"),
}
),
)
resources.collect_icon(session=unbound_session, tenant_id="source", metadata=metadata)
app = make_agent_app_dsl(
App(name="Agent", mode=AppMode.AGENT),
package_ref="agent_1",
packages={
"agent_1": AgentPackage(metadata=AgentPackageMetadata.model_validate(metadata), soul=AgentSoulConfig())
},
dependencies=[],
)
app.site = SiteDsl(title="Agent Site", icon_type="image", icon="i_000001")
with RosterAgentPackageExporter(storage_backend=backend)._build_archive(
app=app, resources=resources
) as exported:
with RosterAgentPackageReader().read(exported.archive) as prepared:
assert prepared.apps["app.yaml"].package.metadata.icon == "i_000001"
assert prepared.apps["app.yaml"].site is not None
assert prepared.apps["app.yaml"].site.icon == "i_000001"
assert prepared.manifest.icons[0].path == "i_000001.png"
assert RosterAgentPackageReader().read_member_bytes(prepared, "i_000001.png", max_bytes=4) == b"icon"