deer-flow/backend/tests/test_capability_api.py
Wenchao An 42334f26d7
feat(capabilities): unify catalog, plugin configuration and agent selection (#5497)
* feat(capabilities): unify catalog, plugin configuration and agent selection

* fix(capabilities): address review isolation, validation and demo issues

* fix(capabilities): preserve concurrent selections and guide launcher repair
2026-09-19 12:14:24 +08:00

258 lines
14 KiB
Python

"""Exercise catalog installation through HTTP and the existing on-disk MCP store."""
import json
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.gateway import capabilities
from app.gateway.deps import get_config
from app.gateway.routers import capabilities as router
from app.gateway.routers import mcp
from deerflow.config.extensions_config import ExtensionsConfig
@pytest.fixture
def capability_client(tmp_path, monkeypatch):
path = tmp_path / "extensions_config.json"
path.write_text(json.dumps({"mcpServers": {"legacy": {"enabled": True, "type": "http", "url": "https://private.example/mcp", "headers": {"Authorization": "Bearer private-secret"}, "capability": {"plugin_id": 42}}}, "skills": {}}))
monkeypatch.setattr(ExtensionsConfig, "resolve_config_path", lambda *args: path)
monkeypatch.setattr(mcp, "reload_extensions_config", lambda: None)
monkeypatch.setattr(mcp, "reset_mcp_tools_cache", lambda: None)
app = FastAPI()
app.dependency_overrides[get_config] = lambda: SimpleNamespace()
@app.middleware("http")
async def identity(request, call_next):
request.state.user = SimpleNamespace(system_role=request.headers.get("test-role", "admin"))
return await call_next(request)
app.include_router(router.router)
app.include_router(mcp.router)
with TestClient(app) as client:
yield client, path
def test_discovery_never_exposes_connection_secrets_and_tolerates_legacy_extras(capability_client):
client, _ = capability_client
result = client.get("/api/capabilities/installations/mcp", headers={"test-role": "user"})
assert result.status_code == 200
assert result.json()["can_manage"] is False
item = result.json()["items"][0]
assert item["id"] and item["plugin_id"] is None
assert item["auth_status"] == "configured" and item["health"] == "unknown"
for excluded in ("private-secret", "private.example", "Authorization", "headers"):
assert excluded not in result.text
def test_catalog_install_edit_disable_delete_preserves_existing_store_and_identity(capability_client):
client, path = capability_client
body = {"plugin_id": "github", "name": "team-code", "configuration": {"enabled": True, "type": "http", "url": "https://example.test/mcp", "headers": {"Authorization": "Bearer team-secret"}}}
response = client.post("/api/capabilities/installations", json=body)
assert response.status_code == 200, response.text
installed = next(item for item in response.json()["items"] if item["name"] == "team-code")
identity = installed["id"]
assert installed["plugin_id"] == "github"
raw = json.loads(path.read_text())
assert raw["mcpServers"]["legacy"]["headers"]["Authorization"] == "Bearer private-secret"
assert raw["mcpServers"]["team-code"]["headers"]["Authorization"] == "Bearer team-secret"
# Old clients do not know about capability metadata; editing must retain identity.
result = client.put("/api/mcp/config/server", json={"server_name": "team-code", "server": {"enabled": False, "type": "http", "url": "https://example.test/new", "headers": {"Authorization": "***"}}})
assert result.status_code == 200, result.text
saved = json.loads(path.read_text())["mcpServers"]["team-code"]
assert saved["capability"]["id"] == identity
assert saved["capability"]["plugin_id"] == "github"
assert saved["headers"]["Authorization"] == "Bearer team-secret"
assert saved["enabled"] is False
duplicate = client.post("/api/capabilities/installations", json=body)
assert duplicate.status_code == 409
assert client.delete("/api/mcp/config/servers/team-code").status_code == 200
assert set(json.loads(path.read_text())["mcpServers"]) == {"legacy"}
recreated = client.post("/api/capabilities/installations", json=body)
replacement = next(item for item in recreated.json()["items"] if item["name"] == "team-code")
assert replacement["id"] != identity
def test_only_admin_may_install_and_bad_configuration_is_422(capability_client):
client, path = capability_client
before = path.read_bytes()
payload = {"plugin_id": "github", "name": "new", "configuration": {"headers": "invalid-secret"}}
assert client.post("/api/capabilities/installations", json=payload, headers={"test-role": "user"}).status_code == 403
invalid = client.post("/api/capabilities/installations", json=payload)
assert invalid.status_code == 422
assert "invalid-secret" not in invalid.text
assert path.read_bytes() == before
def test_adapter_failure_is_isolated_and_lark_configured_is_not_verified(capability_client, monkeypatch):
client, _ = capability_client
monkeypatch.setattr(capabilities, "get_lark_integration_status", lambda *args: SimpleNamespace(installed=True, manifest_version="1", auth=SimpleNamespace(status="authenticated", verified=False)))
assert client.get("/api/capabilities/installations/lark").json()["items"][0]["auth_status"] == "configured"
assert client.get("/api/capabilities/installations/unknown").status_code == 422
assert client.get("/api/capabilities/catalog").status_code == 200
assert client.get("/api/capabilities/installations/mcp").status_code == 200
@pytest.mark.parametrize(
"provider,credentials",
[
("dingtalk", {"access_token": "robot-token", "sign_secret": "SEC-secret"}),
("wecom", {"webhook_key": "webhook-secret"}),
("hubspot", {"access_token": "private-token"}),
],
)
def test_business_install_uses_existing_mcp_lifecycle(capability_client, provider, credentials):
client, path = capability_client
payload = {"plugin_id": provider, "name": "team-" + provider, "configuration": credentials}
assert client.post("/api/capabilities/installations", json=payload, headers={"test-role": "user"}).status_code == 403
response = client.post("/api/capabilities/installations", json=payload)
assert response.status_code == 200, response.text
assert len(response.json()["items"]) == 1
installed = response.json()["items"][0]
assert installed["adapter"] == "mcp" and installed["plugin_id"] == provider
saved = json.loads(path.read_text())["mcpServers"][payload["name"]]
assert saved["args"][-1] == provider
assert set(saved["env"].values()) == set(credentials.values())
for secret in credentials.values():
assert secret not in response.text
assert secret not in client.get("/api/mcp/config").text
# Masked edits and toggles must continue to work without opening Python execution.
masked = client.get("/api/mcp/config").json()["mcp_servers"][payload["name"]]
masked["enabled"] = False
assert client.put("/api/mcp/config/server", json={"server_name": payload["name"], "server": masked}).status_code == 200
masked["enabled"] = True
assert client.put("/api/mcp/config/server", json={"server_name": payload["name"], "server": masked}).status_code == 200
assert client.delete("/api/mcp/config/servers/" + payload["name"]).status_code == 200
assert set(json.loads(path.read_text())["mcpServers"]) == {"legacy"}
def test_business_config_rejects_arbitrary_execution_and_preserves_store(capability_client):
from deerflow.capabilities.business import connection_config
client, path = capability_client
before = path.read_bytes()
payload = {"plugin_id": "hubspot", "name": "bad", "configuration": {"access_token": "private-token", "command": "evil"}}
assert client.post("/api/capabilities/installations", json=payload).status_code == 422
definition = connection_config("hubspot", {"access_token": "private-token"})
for change in ({"args": ["-c", "print(1)"]}, {"args": ["-I", "-m", "other.module", "hubspot"]}, {"env": {**definition["env"], "PYTHONPATH": "/tmp"}}):
response = client.post("/api/mcp/config/servers", json={"mcp_servers": {"bad": {**definition, **change}}})
assert response.status_code == 400
assert path.read_bytes() == before
@pytest.mark.parametrize("configuration", [{"type": "http"}, {"type": "http", "url": ""}, {"type": "http", "url": "file:///tmp/a"}, {"type": "http", "url": "https://user:secret@example.test"}, {"type": "http", "url": "not-a-url"}])
def test_catalog_rejects_incomplete_or_unsafe_http_configuration(capability_client, configuration):
client, path = capability_client
before = path.read_bytes()
response = client.post("/api/capabilities/installations", json={"plugin_id": "github", "name": "bad", "configuration": configuration})
assert response.status_code == 422
assert "secret" not in response.text
assert path.read_bytes() == before
@pytest.mark.parametrize("route", ["/api/mcp/config/servers", "/api/mcp/config"])
@pytest.mark.parametrize("fallback", [False, True])
def test_mcp_writes_reject_colliding_installation_ids(capability_client, route, fallback):
from deerflow.capabilities.runtime import installation_id
client, path = capability_client
raw = json.loads(path.read_text())
identity = installation_id("legacy", {}) if fallback else "shared"
if not fallback:
raw["mcpServers"]["legacy"]["capability"] = {"id": identity}
path.write_text(json.dumps(raw))
before = path.read_bytes()
method = client.post if route.endswith("/servers") else client.put
candidate = {"other": {"type": "http", "url": "https://example.test", "capability": {"id": identity}}}
if route == "/api/mcp/config":
candidate["legacy"] = raw["mcpServers"]["legacy"]
response = method(route, json={"mcp_servers": candidate})
assert response.status_code == 400
assert path.read_bytes() == before
def test_targeted_edit_enable_and_delete_handle_identity_collisions(capability_client):
from deerflow.capabilities.runtime import installation_id
client, path = capability_client
raw = json.loads(path.read_text())
raw["mcpServers"]["second"] = {"type": "http", "url": "https://example.test", "enabled": False}
path.write_text(json.dumps(raw))
before = path.read_bytes()
response = client.put("/api/mcp/config/server", json={"server_name": "second", "server": {"type": "http", "url": "https://example.test", "capability": {"id": installation_id("legacy", {})}}})
assert response.status_code == 400
assert path.read_bytes() == before
raw["mcpServers"]["second"]["capability"] = {"id": installation_id("legacy", {})}
path.write_text(json.dumps(raw))
before = path.read_bytes()
response = client.patch("/api/mcp/config", json={"server_name": "second", "enabled": True})
assert response.status_code == 400
assert path.read_bytes() == before
discovery = client.get("/api/capabilities/installations/mcp").json()["items"]
assert len({item["id"] for item in discovery}) == 2
assert all(item["selectable"] is False for item in discovery)
assert client.delete("/api/mcp/config/servers/second").status_code == 200
assert client.get("/api/capabilities/installations/mcp").json()["items"][0]["selectable"] is True
@pytest.mark.parametrize("provider", ["dingtalk", "wecom", "hubspot"])
def test_stale_bundled_interpreter_can_be_repaired_without_reinstall(capability_client, provider):
import sys
from deerflow.capabilities.business import CREDENTIALS, connection_config
client, path = capability_client
configuration = connection_config(provider, {field: "private-token" for field in CREDENTIALS[provider]})
configuration.update(command="/removed/venv/bin/python", enabled=False, capability={"id": "keep-agent-selection", "plugin_id": provider, "version": "2"})
raw = json.loads(path.read_text())
raw["mcpServers"]["team"] = configuration
path.write_text(json.dumps(raw))
before = path.read_bytes()
response = client.patch("/api/mcp/config", json={"server_name": "team", "enabled": True})
assert response.status_code == 400
assert "different Python interpreter" in response.json()["detail"]
assert sys.executable in response.json()["detail"]
assert "private-token" not in response.text
assert path.read_bytes() == before
masked = client.get("/api/mcp/config").json()["mcp_servers"]["team"]
assert set(masked["env"].values()) == {"***"}
masked.update(command=sys.executable, enabled=True)
response = client.put("/api/mcp/config/server", json={"server_name": "team", "server": masked})
assert response.status_code == 200
repaired = json.loads(path.read_text())["mcpServers"]["team"]
assert repaired["capability"] == configuration["capability"]
assert repaired["env"] == configuration["env"]
assert repaired["command"] == sys.executable
assert client.patch("/api/mcp/config", json={"server_name": "team", "enabled": True}).status_code == 200
def test_delete_recovers_multiple_legacy_identity_collisions(capability_client):
from deerflow.capabilities.runtime import installation_id
client, path = capability_client
raw = json.loads(path.read_text())
raw["mcpServers"].update(
{
"legacy-peer": {"type": "http", "url": "https://example.test", "capability": {"id": installation_id("legacy", {})}},
"pair-two-a": {"type": "http", "url": "https://example.test", "capability": {"id": "pair-two"}},
"pair-two-b": {"type": "http", "url": "https://example.test", "capability": {"id": "pair-two"}, "enabled": False},
"unrelated": {"type": "http", "url": "https://example.test"},
}
)
path.write_text(json.dumps(raw))
before = path.read_bytes()
assert client.delete("/api/mcp/config/servers/legacy-peer", headers={"test-role": "user"}).status_code == 403
assert path.read_bytes() == before
for removed in ["unrelated", "legacy-peer", "pair-two-b"]:
before_raw = json.loads(path.read_text())
assert client.delete(f"/api/mcp/config/servers/{removed}").status_code == 200
del before_raw["mcpServers"][removed]
assert json.loads(path.read_text()) == before_raw
items = client.get("/api/capabilities/installations/mcp").json()["items"]
ambiguous = [item for item in items if not item["selectable"]]
assert len(ambiguous) == {"unrelated": 4, "legacy-peer": 2, "pair-two-b": 0}[removed]
assert client.patch("/api/mcp/config", json={"server_name": "legacy", "enabled": False}).status_code == 200
assert client.post("/api/mcp/config/servers", json={"mcp_servers": {"recovered": {"type": "http", "url": "https://example.test"}}}).status_code == 200