395 lines
14 KiB
Python
395 lines
14 KiB
Python
"""Tests for the skill ``detail`` field (long-form annotation editable by
|
|
the skill owner or an admin).
|
|
|
|
Covers:
|
|
- ``SkillStore`` persists ``detail`` through create / ensure_legacy / update /
|
|
update_admin, and owner-only ``update`` still refuses non-owners.
|
|
- ``_skill_to_response`` surfaces ``detail`` from the ownership record.
|
|
- ``SkillResponse`` / ``SkillDetailRequest`` model shapes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from app.gateway.routers import mcp as mcp_router
|
|
from app.gateway.routers.skills import (
|
|
SkillDetailRequest,
|
|
SkillResponse,
|
|
_can_view_skill_source,
|
|
_skill_to_response,
|
|
)
|
|
from deerflow.client import DeerFlowClient
|
|
from deerflow.config import paths as paths_module
|
|
from deerflow.config.extensions_config import reset_extensions_config
|
|
from deerflow.persistence.skills import MemorySkillStore
|
|
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
|
|
from deerflow.skills.types import SkillCategory
|
|
|
|
|
|
@pytest.fixture
|
|
def isolated_paths(tmp_path, monkeypatch):
|
|
"""Redirect get_paths() at a fresh temp directory so usage lookups in
|
|
``_skill_to_response`` never touch real state."""
|
|
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
|
monkeypatch.setattr(paths_module, "_paths", None)
|
|
yield tmp_path
|
|
monkeypatch.setattr(paths_module, "_paths", None)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SkillStore: detail persistence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skill_store_create_carries_detail() -> None:
|
|
store = MemorySkillStore()
|
|
created = await store.create(
|
|
{"name": "alpha-tool", "owner_user_id": "user-a", "detail": "初始详情"}
|
|
)
|
|
assert created["detail"] == "初始详情"
|
|
fetched = await store.get_any("alpha-tool")
|
|
assert fetched is not None and fetched["detail"] == "初始详情"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skill_store_create_defaults_detail_to_none() -> None:
|
|
store = MemorySkillStore()
|
|
created = await store.create({"name": "alpha-tool", "owner_user_id": "user-a"})
|
|
assert created["detail"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skill_store_owner_can_set_and_clear_detail() -> None:
|
|
store = MemorySkillStore()
|
|
await store.create({"name": "alpha-tool", "owner_user_id": "user-a"})
|
|
|
|
updated = await store.update("alpha-tool", "user-a", {"detail": "技能用途说明"})
|
|
assert updated is not None and updated["detail"] == "技能用途说明"
|
|
|
|
# Clearing the annotation persists None.
|
|
cleared = await store.update("alpha-tool", "user-a", {"detail": None})
|
|
assert cleared is not None and cleared["detail"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skill_store_update_detail_rejects_non_owner() -> None:
|
|
store = MemorySkillStore()
|
|
await store.create({"name": "alpha-tool", "owner_user_id": "user-a"})
|
|
|
|
# A different user cannot write detail via the owner-scoped update.
|
|
assert await store.update("alpha-tool", "user-b", {"detail": "x"}) is None
|
|
record = await store.get_any("alpha-tool")
|
|
assert record is not None and record["detail"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skill_store_admin_can_set_detail_on_any_skill() -> None:
|
|
store = MemorySkillStore()
|
|
await store.create({"name": "alpha-tool", "owner_user_id": "user-a"})
|
|
|
|
# Admin override writes detail regardless of ownership.
|
|
updated = await store.update_admin("alpha-tool", {"detail": "管理员补充说明"})
|
|
assert updated is not None and updated["detail"] == "管理员补充说明"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skill_store_partial_update_preserves_detail() -> None:
|
|
"""Updating only ``published`` must not wipe an existing ``detail``."""
|
|
store = MemorySkillStore()
|
|
await store.create({"name": "alpha-tool", "owner_user_id": "user-a"})
|
|
await store.update("alpha-tool", "user-a", {"detail": "保留我"})
|
|
|
|
await store.update("alpha-tool", "user-a", {"published": True})
|
|
record = await store.get_any("alpha-tool")
|
|
assert record is not None
|
|
assert record["detail"] == "保留我"
|
|
assert record["published"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skill_store_ensure_legacy_carries_detail() -> None:
|
|
store = MemorySkillStore()
|
|
record = await store.ensure_legacy(
|
|
{"name": "builtin-skill", "owner_user_id": None, "published": True, "detail": "内置技能说明"}
|
|
)
|
|
assert record["detail"] == "内置技能说明"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Response mapping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_skill_response_detail_defaults_to_none() -> None:
|
|
resp = SkillResponse(name="x", description="d", category=SkillCategory.CUSTOM)
|
|
assert resp.detail is None
|
|
|
|
|
|
def test_skill_detail_request_defaults_to_empty_string() -> None:
|
|
assert SkillDetailRequest().detail == ""
|
|
assert SkillDetailRequest(detail="hello").detail == "hello"
|
|
|
|
|
|
def test_skill_to_response_surfaces_detail_from_ownership(isolated_paths) -> None:
|
|
skill = SimpleNamespace(
|
|
name="alpha-tool",
|
|
description="desc",
|
|
license=None,
|
|
category=SkillCategory.CUSTOM,
|
|
enabled=True,
|
|
)
|
|
ownership = {
|
|
"name": "alpha-tool",
|
|
"owner_user_id": "user-a",
|
|
"published": False,
|
|
"builtin": False,
|
|
"detail": "这是技能的详细介绍",
|
|
}
|
|
resp = _skill_to_response(skill, ownership)
|
|
assert resp.detail == "这是技能的详细介绍"
|
|
assert resp.owner_user_id == "user-a"
|
|
|
|
|
|
def test_skill_to_response_detail_none_without_ownership(isolated_paths) -> None:
|
|
skill = SimpleNamespace(
|
|
name="builtin-skill",
|
|
description="desc",
|
|
license=None,
|
|
category=SkillCategory.PUBLIC,
|
|
enabled=True,
|
|
)
|
|
resp = _skill_to_response(skill, None)
|
|
assert resp.detail is None
|
|
assert resp.builtin is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SKILL.md source visibility — admin / publisher only
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_admin_can_view_any_skill_source() -> None:
|
|
# Admin sees a built-in skill's source (no owner).
|
|
assert _can_view_skill_source(SkillCategory.PUBLIC, None, "user-a", is_admin=True) is True
|
|
# ...and another user's custom skill.
|
|
assert _can_view_skill_source(SkillCategory.CUSTOM, "user-b", "user-a", is_admin=True) is True
|
|
|
|
|
|
def test_publisher_can_view_own_custom_skill_source() -> None:
|
|
assert _can_view_skill_source(SkillCategory.CUSTOM, "user-a", "user-a", is_admin=False) is True
|
|
|
|
|
|
def test_non_owner_cannot_view_custom_skill_source() -> None:
|
|
# A different user — even if the skill is published to them — cannot read source.
|
|
assert _can_view_skill_source(SkillCategory.CUSTOM, "user-b", "user-a", is_admin=False) is False
|
|
|
|
|
|
def test_non_admin_cannot_view_builtin_skill_source() -> None:
|
|
# Built-in / public skills have no publisher, so non-admins never see source.
|
|
assert _can_view_skill_source(SkillCategory.PUBLIC, None, "user-a", is_admin=False) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unified extensions-config writer regressions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def raw_extensions_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
config_path = tmp_path / "extensions_config.json"
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(config_path))
|
|
reset_extensions_config()
|
|
try:
|
|
yield config_path
|
|
finally:
|
|
reset_extensions_config()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mcp_router_patches_raw_document_without_overwriting_skills(
|
|
raw_extensions_config: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
original = {
|
|
"mcpServers": {"old": {"command": "$OLD_COMMAND", "futureMcp": "old"}},
|
|
"skills": {
|
|
"offline-skill": {
|
|
"enabled": False,
|
|
"display": {"enabled": True, "mode": "list"},
|
|
"futureSkill": {"keep": True},
|
|
}
|
|
},
|
|
"futureTopLevel": {"placeholder": "$DO_NOT_RESOLVE"},
|
|
}
|
|
raw_extensions_config.write_text(json.dumps(original), encoding="utf-8")
|
|
|
|
async def _allow_admin(_request) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(mcp_router, "_require_admin", _allow_admin)
|
|
before_update = await mcp_router.get_mcp_configuration(SimpleNamespace())
|
|
assert before_update.mcp_servers["old"].command == "$OLD_COMMAND"
|
|
assert before_update.mcp_servers["old"].model_extra == {"futureMcp": "old"}
|
|
|
|
response = await mcp_router.update_mcp_configuration(
|
|
mcp_router.McpConfigUpdateRequest(
|
|
mcp_servers={
|
|
"replacement": mcp_router.McpServerConfigResponse(
|
|
type="stdio",
|
|
command="python",
|
|
env={"TOKEN": "$MCP_TOKEN"},
|
|
)
|
|
}
|
|
),
|
|
SimpleNamespace(),
|
|
)
|
|
|
|
persisted = json.loads(raw_extensions_config.read_text(encoding="utf-8"))
|
|
assert set(response.mcp_servers) == {"replacement"}
|
|
assert persisted["mcpServers"]["replacement"]["env"]["TOKEN"] == "$MCP_TOKEN"
|
|
assert persisted["skills"] == original["skills"]
|
|
assert persisted["futureTopLevel"] == original["futureTopLevel"]
|
|
|
|
|
|
def test_embedded_client_mcp_and_skill_updates_patch_only_owned_fields(
|
|
raw_extensions_config: Path,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
skills_root = tmp_path / "skills"
|
|
skill_file = skills_root / "public" / "demo-dir" / "SKILL.md"
|
|
skill_file.parent.mkdir(parents=True)
|
|
skill_file.write_text(
|
|
"---\nname: demo-skill\ndescription: Demo\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
storage = LocalSkillStorage(host_path=str(skills_root))
|
|
import deerflow.skills.storage as storage_module
|
|
|
|
monkeypatch.setattr(storage_module, "get_or_new_skill_storage", lambda **_kwargs: storage)
|
|
original = {
|
|
"mcpServers": {"old": {"command": "$OLD_COMMAND"}},
|
|
"skills": {
|
|
"demo-skill": {
|
|
"enabled": True,
|
|
"display": {"enabled": True, "mode": "list"},
|
|
"futureSkill": "keep",
|
|
}
|
|
},
|
|
"futureTopLevel": ["keep"],
|
|
}
|
|
raw_extensions_config.write_text(json.dumps(original), encoding="utf-8")
|
|
|
|
client = object.__new__(DeerFlowClient)
|
|
client._agent = object()
|
|
client._agent_config_key = ("cached",)
|
|
assert client.get_mcp_config()["mcp_servers"]["old"]["command"] == "$OLD_COMMAND"
|
|
|
|
before_invalid_update = raw_extensions_config.read_bytes()
|
|
with pytest.raises(ValueError):
|
|
client.update_mcp_config({"invalid": {"command": "python", "env": ["not-a-mapping"]}})
|
|
assert raw_extensions_config.read_bytes() == before_invalid_update
|
|
|
|
client.update_mcp_config(
|
|
{
|
|
"client-server": {
|
|
"type": "stdio",
|
|
"command": "python",
|
|
"env": {"TOKEN": "$CLIENT_SECRET"},
|
|
"futureMcp": {"keep": True},
|
|
}
|
|
}
|
|
)
|
|
persisted = json.loads(raw_extensions_config.read_text(encoding="utf-8"))
|
|
assert persisted["mcpServers"]["client-server"]["env"]["TOKEN"] == "$CLIENT_SECRET"
|
|
assert persisted["mcpServers"]["client-server"]["futureMcp"] == {"keep": True}
|
|
assert persisted["skills"] == original["skills"]
|
|
assert persisted["futureTopLevel"] == ["keep"]
|
|
|
|
updated = client.update_skill("demo-skill", enabled=False)
|
|
persisted = json.loads(raw_extensions_config.read_text(encoding="utf-8"))
|
|
assert updated["enabled"] is False
|
|
assert persisted["skills"]["demo-skill"]["enabled"] is False
|
|
assert persisted["skills"]["demo-skill"]["display"] == original["skills"]["demo-skill"]["display"]
|
|
assert persisted["skills"]["demo-skill"]["futureSkill"] == "keep"
|
|
assert persisted["mcpServers"]["client-server"]["futureMcp"] == {"keep": True}
|
|
assert persisted["futureTopLevel"] == ["keep"]
|
|
assert client._agent is None
|
|
assert client._agent_config_key is None
|
|
|
|
|
|
def test_extensions_config_writer_serializes_cross_process_patches(
|
|
raw_extensions_config: Path,
|
|
) -> None:
|
|
raw_extensions_config.write_text(
|
|
json.dumps({"mcpServers": {}, "skills": {}, "concurrent": {}}),
|
|
encoding="utf-8",
|
|
)
|
|
script = """
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from deerflow.config.extensions_config import patch_extensions_config
|
|
|
|
Path(sys.argv[3]).write_text("ready", encoding="utf-8")
|
|
while not Path(sys.argv[4]).exists():
|
|
time.sleep(0.01)
|
|
|
|
def update(document):
|
|
current = document.setdefault("concurrent", {})
|
|
time.sleep(0.25)
|
|
current[sys.argv[2]] = True
|
|
|
|
patch_extensions_config(sys.argv[1], update)
|
|
"""
|
|
backend_root = Path(__file__).resolve().parents[1]
|
|
start_path = raw_extensions_config.with_suffix(".start")
|
|
ready_paths = {
|
|
key: raw_extensions_config.with_suffix(f".{key}.ready")
|
|
for key in ("mcp", "skill")
|
|
}
|
|
processes = [
|
|
subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
script,
|
|
str(raw_extensions_config),
|
|
key,
|
|
str(ready_paths[key]),
|
|
str(start_path),
|
|
],
|
|
cwd=backend_root,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
for key in ("mcp", "skill")
|
|
]
|
|
deadline = time.monotonic() + 30
|
|
while not all(path.exists() for path in ready_paths.values()):
|
|
if time.monotonic() >= deadline:
|
|
for process in processes:
|
|
process.terminate()
|
|
pytest.fail("Timed out waiting for config-writer subprocesses")
|
|
time.sleep(0.01)
|
|
start_path.touch()
|
|
failures: list[str] = []
|
|
for process in processes:
|
|
_stdout, stderr = process.communicate(timeout=30)
|
|
if process.returncode != 0:
|
|
failures.append(stderr)
|
|
assert failures == []
|
|
|
|
persisted = json.loads(raw_extensions_config.read_text(encoding="utf-8"))
|
|
assert persisted["concurrent"] == {"mcp": True, "skill": True}
|