Files
arc53--docsgpt/tests/api/test_agent_portability.py
T
wehub-resource-sync fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

481 lines
16 KiB
Python

"""Tests for agent YAML export/import (application.api.user.agents.portability).
These exercise real SQL against the ephemeral ``pg_conn`` fixture, calling
the dependency-injected serialize/plan/apply functions directly so the
logic is covered without a running Flask app.
"""
from __future__ import annotations
from unittest.mock import Mock
import pytest
from application.agents.default_tools import default_tool_id
from application.api.user.agents.portability import (
API_VERSION,
AgentImportError,
agent_to_yaml,
apply_import,
ensure_agent_slug,
parse_agent_yaml,
plan_import,
serialize_agent,
)
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.prompts import PromptsRepository
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.repositories.user_tools import UserToolsRepository
pytestmark = pytest.mark.integration
def _make_agent(conn, user, **kwargs):
defaults = dict(description="d", chunks=2, retriever="classic")
defaults.update(kwargs)
return AgentsRepository(conn).create(user, kwargs.pop("name", "Bot"), "published", **defaults)
def _doc(**spec):
spec.setdefault("name", "Imported")
return {
"apiVersion": API_VERSION,
"kind": "Agent",
"metadata": {"slug": spec.pop("_slug", "imported")},
"spec": spec,
}
def test_serialize_redacts_tool_secret(pg_conn):
user = "u_redact"
src = SourcesRepository(pg_conn).create("Docs", user_id=user, type="file")
prompt = PromptsRepository(pg_conn).create(user, "P", "You are helpful")
tool = UserToolsRepository(pg_conn).create(
user,
"brave",
config={"encrypted_credentials": "SECRETBLOB", "region": "US"},
custom_name="My Brave",
display_name="Brave Search",
description="search",
config_requirements={"token": {"secret": True, "required": True}, "region": {}},
actions=[],
)
agent = AgentsRepository(pg_conn).create(
user,
"Support Bot",
"published",
description="help desk",
chunks=3,
retriever="classic",
prompt_id=str(prompt["id"]),
source_id=str(src["id"]),
tools=[str(tool["id"])],
)
export = serialize_agent(pg_conn, agent, user)
text = agent_to_yaml(export)
assert "SECRETBLOB" not in text
assert "encrypted_credentials" not in text
tool_entry = export["spec"]["tools"][0]
assert tool_entry["type"] == "brave"
assert tool_entry["requires_secrets"] == ["token"]
assert "encrypted_credentials" not in tool_entry["config"]
assert tool_entry["config"] == {"region": "US"}
assert export["spec"]["prompt"] == {"name": "P", "content": "You are helpful"}
assert export["spec"]["sources"][0]["name"] == "Docs"
def test_export_assigns_unique_slug(pg_conn):
user = "u_slug"
a1 = AgentsRepository(pg_conn).create(user, "My Bot", "draft")
a2 = AgentsRepository(pg_conn).create(user, "My Bot", "draft")
s1 = ensure_agent_slug(pg_conn, a1, user)
a2_reload = AgentsRepository(pg_conn).get(str(a2["id"]), user)
s2 = ensure_agent_slug(pg_conn, a2_reload, user)
assert s1 == "my-bot"
assert s2 == "my-bot-2"
def test_round_trip_same_user_idempotent(pg_conn):
user = "u_round"
src = SourcesRepository(pg_conn).create("KB", user_id=user, type="file")
prompt = PromptsRepository(pg_conn).create(user, "Sys", "Be nice")
scheduler_id = default_tool_id("scheduler")
agent = AgentsRepository(pg_conn).create(
user,
"Bot",
"published",
description="d",
chunks=2,
retriever="classic",
prompt_id=str(prompt["id"]),
source_id=str(src["id"]),
tools=[scheduler_id],
)
ensure_agent_slug(pg_conn, agent, user)
agent = AgentsRepository(pg_conn).get(str(agent["id"]), user)
doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
r1 = apply_import(pg_conn, user, doc)
r2 = apply_import(pg_conn, user, doc)
assert r1["agent_id"] == r2["agent_id"] == str(agent["id"]) # matched by id
assert len(PromptsRepository(pg_conn).list_for_user(user)) == 1 # no dup prompt
imported = AgentsRepository(pg_conn).get(r1["agent_id"], user)
assert imported["status"] == "published" # update preserves the live status
assert [str(s) for s in imported["extra_source_ids"]] == [str(src["id"])]
assert imported["tools"] == [scheduler_id] # builtin passthrough
def test_update_preserves_published_status(pg_conn):
"""Re-importing over a published agent must not revert it to draft.
The agent's API key and any active users keep working; only the content
is synced. A new agent (no match) still lands as a draft.
"""
user = "u_pub"
agent = AgentsRepository(pg_conn).create(
user,
"Live Bot",
"published",
description="old",
chunks=2,
retriever="classic",
)
slug = ensure_agent_slug(pg_conn, agent, user)
doc = {
"apiVersion": API_VERSION,
"kind": "Agent",
"metadata": {"id": str(agent["id"]), "slug": slug},
"spec": {"name": "Live Bot", "description": "new", "retriever": "classic"},
}
result = apply_import(pg_conn, user, doc)
assert result["action"] == "updated"
assert result["status"] == "published" # response reports the preserved status
updated = AgentsRepository(pg_conn).get(str(agent["id"]), user)
assert updated["status"] == "published" # stayed live
assert updated["description"] == "new" # content still synced
def test_import_by_slug_idempotent(pg_conn):
user = "u_slug_import"
doc = _doc(name="Slug Bot", _slug="slug-bot", retriever="classic")
r1 = apply_import(pg_conn, user, doc)
r2 = apply_import(pg_conn, user, doc)
assert r1["action"] == "created"
assert r2["action"] == "updated"
assert r1["agent_id"] == r2["agent_id"]
assert len(AgentsRepository(pg_conn).list_for_user(user)) == 1
def test_import_missing_source_drafts_and_warns(pg_conn):
user = "u_missing"
doc = _doc(
name="No Source Bot",
_slug="no-source",
sources=[{"name": "Nonexistent KB", "type": "file"}],
)
result = apply_import(pg_conn, user, doc)
assert result["status"] == "draft" # new agents are created as drafts
assert any("Nonexistent KB" in w for w in result["warnings"])
agent = AgentsRepository(pg_conn).get(result["agent_id"], user)
assert agent["status"] == "draft"
assert list(agent["extra_source_ids"]) == []
def test_import_matches_existing_source_by_name(pg_conn):
user = "u_match"
src = SourcesRepository(pg_conn).create("Product Docs", user_id=user, type="file")
doc = _doc(
name="Matcher",
_slug="matcher",
sources=[{"name": "product docs", "type": "file"}], # case-insensitive
)
result = apply_import(pg_conn, user, doc)
agent = AgentsRepository(pg_conn).get(result["agent_id"], user)
assert [str(s) for s in agent["extra_source_ids"]] == [str(src["id"])]
assert result["warnings"] == []
def test_plan_classifies_references(pg_conn):
user = "u_plan"
SourcesRepository(pg_conn).create("Known", user_id=user, type="file")
tool = UserToolsRepository(pg_conn).create(
user, "brave", custom_name="My Brave", display_name="Brave", config={},
)
doc = _doc(
name="Plan Bot",
sources=[{"name": "Known"}, {"name": "Unknown"}],
tools=[
{"type": "scheduler", "builtin": True},
{"type": "brave", "name": "My Brave"},
],
)
plan = plan_import(pg_conn, user, doc)
statuses = {s["name"]: s["status"] for s in plan["sources"]}
assert statuses == {"Known": "matched", "Unknown": "missing"}
tool_statuses = [t["status"] for t in plan["tools"]]
assert tool_statuses[0] == "builtin"
assert tool_statuses[1] == "reuse"
assert plan["tools"][1]["target_id"] == str(tool["id"])
assert plan["target"]["action"] == "create"
def test_apply_creates_tool_with_supplied_secret(pg_conn, monkeypatch):
user = "u_tool"
fake_tool = Mock()
fake_tool.get_config_requirements.return_value = {
"api_key": {"secret": True, "required": True, "label": "API Key"}
}
fake_tool.get_actions_metadata.return_value = []
monkeypatch.setattr(
"application.api.user.agents.portability._tool_instance",
lambda tool_type: fake_tool,
)
doc = _doc(
name="Tool Bot",
_slug="tool-bot",
tools=[
{
"type": "brave",
"name": "My API",
"config": {},
"requires_secrets": ["api_key"],
}
],
)
# No secret supplied -> tool skipped, agent still created.
res = apply_import(pg_conn, user, doc)
agent = AgentsRepository(pg_conn).get(res["agent_id"], user)
assert agent["tools"] == []
assert any("api_key" in w for w in res["warnings"])
# Secret supplied via resolution -> tool created and linked, secret encrypted.
res2 = apply_import(
pg_conn,
user,
doc,
resolution={"tools": {"tool-0": {"secrets": {"api_key": "PLAINTEXT_KEY"}}}},
)
agent2 = AgentsRepository(pg_conn).get(res2["agent_id"], user)
assert len(agent2["tools"]) == 1
tool_row = UserToolsRepository(pg_conn).get_any(agent2["tools"][0], user)
assert "PLAINTEXT_KEY" not in str(tool_row["config"])
assert "encrypted_credentials" in tool_row["config"]
def test_parse_rejects_bad_documents():
with pytest.raises(AgentImportError):
parse_agent_yaml("kind: NotAgent\napiVersion: docsgpt.arc53.com/v1\nspec:\n name: x\n")
with pytest.raises(AgentImportError):
parse_agent_yaml("kind: Agent\napiVersion: other/v1\nspec:\n name: x\n")
with pytest.raises(AgentImportError):
parse_agent_yaml("kind: Agent\napiVersion: docsgpt.arc53.com/v1\nspec: {}\n")
with pytest.raises(AgentImportError):
parse_agent_yaml(
"kind: Agent\napiVersion: docsgpt.arc53.com/v1\n"
"spec:\n name: x\n agent_type: workflow\n"
)
def test_parse_rejects_yaml_aliases():
bomb = (
"kind: Agent\napiVersion: docsgpt.arc53.com/v1\n"
"spec:\n name: &a x\n description: *a\n"
)
with pytest.raises(AgentImportError):
parse_agent_yaml(bomb)
def test_update_clears_removed_json_schema(pg_conn):
"""On update the YAML is authoritative — removing json_schema clears it."""
user = "u_clear"
agent = AgentsRepository(pg_conn).create(
user,
"Clear Bot",
"published",
description="d",
chunks=2,
retriever="classic",
json_schema={"type": "object"},
)
slug = ensure_agent_slug(pg_conn, agent, user)
doc = {
"apiVersion": API_VERSION,
"kind": "Agent",
"metadata": {"slug": slug},
"spec": {"name": "Clear Bot", "retriever": "classic", "json_schema": None},
}
result = apply_import(pg_conn, user, doc)
assert result["action"] == "updated"
updated = AgentsRepository(pg_conn).get(str(agent["id"]), user)
assert updated["json_schema"] is None
def test_custom_tool_dedup_across_reimport(pg_conn):
user = "u_dedup"
tool = UserToolsRepository(pg_conn).create(
user, "brave", custom_name="My Brave", display_name="Brave", config={},
)
agent = AgentsRepository(pg_conn).create(
user,
"Dedup Bot",
"published",
description="d",
chunks=2,
retriever="classic",
tools=[str(tool["id"])],
)
ensure_agent_slug(pg_conn, agent, user)
agent = AgentsRepository(pg_conn).get(str(agent["id"]), user)
doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
apply_import(pg_conn, user, doc)
apply_import(pg_conn, user, doc)
assert len(UserToolsRepository(pg_conn).list_for_user(user)) == 1 # reused, never duplicated
final = AgentsRepository(pg_conn).get(str(agent["id"]), user)
assert final["tools"] == [str(tool["id"])]
def test_import_rejects_ssrf_tool_url(pg_conn, monkeypatch):
user = "u_ssrf"
fake_tool = Mock()
fake_tool.get_config_requirements.return_value = {}
fake_tool.get_actions_metadata.return_value = []
monkeypatch.setattr(
"application.api.user.agents.portability._tool_instance",
lambda tool_type: fake_tool,
)
doc = _doc(
name="SSRF Bot",
_slug="ssrf",
tools=[
{
"type": "mcp_tool",
"name": "evil",
"config": {"server_url": "http://169.254.169.254/latest/meta-data"},
}
],
)
result = apply_import(pg_conn, user, doc)
agent = AgentsRepository(pg_conn).get(result["agent_id"], user)
assert agent["tools"] == []
assert any("unsafe" in w.lower() for w in result["warnings"])
def test_import_rejects_unowned_source_mapping(pg_conn):
owner, attacker = "u_owner", "u_attacker"
src = SourcesRepository(pg_conn).create("Secret KB", user_id=owner, type="file")
doc = _doc(name="IDOR Bot", _slug="idor", sources=[{"name": "Their Source"}])
# Attacker maps the missing source to the owner's source id.
result = apply_import(
pg_conn, attacker, doc, resolution={"sources": {"Their Source": str(src["id"])}}
)
agent = AgentsRepository(pg_conn).get(result["agent_id"], attacker)
assert list(agent["extra_source_ids"]) == [] # not linked
assert any("not yours" in w for w in result["warnings"])
def test_apply_tolerates_plan_shaped_resolution(pg_conn):
"""Echoing the plan's list-shaped resolution back must not crash apply."""
user = "u_shape"
doc = _doc(name="Shape Bot", _slug="shape", sources=[{"name": "X"}])
result = apply_import(
pg_conn, user, doc, resolution={"sources": [], "tools": [], "models": []}
)
assert result["action"] == "created"
def test_update_clears_models_and_resets_prompt(pg_conn):
"""A re-imported file that drops models and prompt clears them on update."""
user = "u_clear2"
prompt = PromptsRepository(pg_conn).create(user, "Sys", "Be terse")
agent = AgentsRepository(pg_conn).create(
user,
"Model Bot",
"published",
description="d",
chunks=2,
retriever="classic",
prompt_id=str(prompt["id"]),
models=["gpt-4o"],
default_model_id="gpt-4o",
)
slug = ensure_agent_slug(pg_conn, agent, user)
doc = {
"apiVersion": API_VERSION,
"kind": "Agent",
"metadata": {"slug": slug},
"spec": {
"name": "Model Bot",
"retriever": "classic",
"prompt": "default",
"model": {"default": "", "available": []},
},
}
apply_import(pg_conn, user, doc)
updated = AgentsRepository(pg_conn).get(str(agent["id"]), user)
assert not updated["models"]
assert not updated["default_model_id"]
assert updated["prompt_id"] is None # reset to default
def test_tool_skipped_when_encryption_fails(pg_conn, monkeypatch):
user = "u_encfail"
fake_tool = Mock()
fake_tool.get_config_requirements.return_value = {
"api_key": {"secret": True, "required": True}
}
fake_tool.get_actions_metadata.return_value = []
monkeypatch.setattr(
"application.api.user.agents.portability._tool_instance",
lambda tool_type: fake_tool,
)
# Force credential encryption to fail (returns "").
monkeypatch.setattr(
"application.api.user.tools.routes.encrypt_credentials",
lambda creds, user_id: "",
)
doc = _doc(
name="EncFail Bot",
_slug="encfail",
tools=[{"type": "brave", "name": "X", "config": {}, "requires_secrets": ["api_key"]}],
)
result = apply_import(
pg_conn,
user,
doc,
resolution={"tools": {"tool-0": {"secrets": {"api_key": "K"}}}},
)
agent = AgentsRepository(pg_conn).get(result["agent_id"], user)
assert agent["tools"] == []
assert any("encryption failed" in w for w in result["warnings"])