deer-flow/backend/tests/test_skill_mutation_admin.py
Ryker_Feng d1d0958bea
feat(skills): add guarded evolution host APIs (#5645)
* feat(skills): add guarded evolution host APIs

* docs(agents): avoid growing saturated guidance chains

* fix(skills): address mutation host review findings

* fix(skills): order name fence before projection lock

* fix(skills): preserve composite toggle lock order

* fix(skills): address catalog and pagination review feedback

* docs: keep subagent guidance within merged instruction budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-18 21:17:02 +08:00

145 lines
5.5 KiB
Python

"""Recovery is an authenticated host operation, never a plugin route."""
from dataclasses import dataclass
from types import SimpleNamespace
import httpx
import pytest
from fastapi import FastAPI, Request
from deerflow.skills.mutations.workers import MutationWorkers
@pytest.mark.asyncio
@pytest.mark.parametrize("role,source,status", [("user", None, 403), ("admin", "pat", 403), ("admin", None, 200)])
async def test_admin_recovery_auth_and_off_loop(role, source, status):
import threading
from app.gateway.routers.skill_mutations import router
loop_thread = threading.get_ident()
calls = []
@dataclass
class Result:
operation_id: str
publication: str = "APPLIED"
class Recovery:
def list_operations(self, *, limit, after_id):
assert threading.get_ident() != loop_thread
assert limit == 51 and after_id is None
calls.append("list")
return (Result("a" * 32),)
def get_operation(self, operation_id):
assert threading.get_ident() != loop_thread
calls.append("query")
return Result(operation_id)
def recover_operation(self, operation_id):
assert threading.get_ident() != loop_thread
calls.append("recover")
return Result(operation_id)
app = FastAPI()
workers = MutationWorkers()
app.state.skill_mutation_host = SimpleNamespace(workers=workers, recovery=Recovery())
@app.middleware("http")
async def identity(request: Request, call_next):
request.state.user = SimpleNamespace(id="admin", system_role=role)
request.state.auth_source = source
return await call_next(request)
app.include_router(router)
try:
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
base = "/api/skill-mutations/operations/" + "a" * 32
listed = await client.get("/api/skill-mutations/operations")
assert listed.status_code == status
if status == 200:
assert listed.json()["items"][0]["operation_id"] == "a" * 32
assert (await client.get(base)).status_code == status
assert (await client.post(base + "/recover")).status_code == status
assert calls == (["list", "query", "recover"] if status == 200 else [])
finally:
await workers.close()
@pytest.mark.asyncio
async def test_admin_errors_do_not_return_database_or_candidate_content():
from deerflow_extension_api import HostCapabilityError
from app.gateway.routers.skill_mutations import router
class Recovery:
def get_operation(self, operation_id):
raise HostCapabilityError("NOT_FOUND_OR_FORBIDDEN", "candidate content must not leak")
app = FastAPI()
workers = MutationWorkers()
app.state.skill_mutation_host = SimpleNamespace(workers=workers, recovery=Recovery())
@app.middleware("http")
async def identity(request: Request, call_next):
request.state.user = SimpleNamespace(id="admin", system_role="admin")
return await call_next(request)
app.include_router(router)
try:
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
response = await client.get("/api/skill-mutations/operations/" + "a" * 32)
assert response.status_code == 404
assert "candidate content" not in response.text
finally:
await workers.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("count,limit", [(0, 2), (1, 2), (2, 2), (3, 2), (3, 1)])
async def test_operation_pagination_terminates_with_null_cursor(count, limit):
from app.gateway.routers.skill_mutations import router
@dataclass
class Result:
operation_id: str
operations = [Result(f"{number:032x}") for number in range(1, count + 1)]
class Recovery:
def list_operations(self, *, limit, after_id):
return tuple(row for row in operations if after_id is None or row.operation_id > after_id)[:limit]
app = FastAPI()
workers = MutationWorkers()
app.state.skill_mutation_host = SimpleNamespace(workers=workers, recovery=Recovery())
@app.middleware("http")
async def identity(request: Request, call_next):
request.state.user = SimpleNamespace(id="admin", system_role="admin")
return await call_next(request)
app.include_router(router)
try:
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
params = {"limit": limit}
seen = []
for _ in range(count + 1):
response = await client.get("/api/skill-mutations/operations", params=params)
assert response.status_code == 200
page = response.json()
seen.extend(row["operation_id"] for row in page["items"])
assert page["has_more"] == (page["next_cursor"] is not None)
if page["next_cursor"] is None:
break
params["after_id"] = page["next_cursor"]
else:
pytest.fail("Pagination failed to terminate")
assert seen == [row.operation_id for row in operations]
# A stale end cursor must not echo itself back forever.
empty = await client.get("/api/skill-mutations/operations", params={"after_id": "f" * 32, "limit": limit})
assert empty.json() == {"items": [], "has_more": False, "next_cursor": None}
finally:
await workers.close()