Files

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}