deer-flow/backend/tests/test_extension_request_evidence.py
Xuehao Xu 4fe64ac916
feat(extensions): add request-scoped run evidence access (#5727)
* feat(extensions): add request-scoped run evidence reader

* fix(extensions): reject padded evidence reader identities
2026-09-23 15:25:45 +08:00

112 lines
5.3 KiB
Python

"""Request evidence access through real contributed routes and host auth."""
import asyncio
from dataclasses import asdict
from types import SimpleNamespace
import httpx
import pytest
from deerflow_extension_api import InvalidRunEvidenceCursor, require_run_evidence_reader, resolve_run_evidence_reader
from fastapi import APIRouter, HTTPException, Request
from deerflow.extensions.run_evidence import StoreRunEvidenceReader, StoreRunEvidenceReaderFactory
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.runs.store.memory import MemoryRunStore
@pytest.fixture
def evidence_app(monkeypatch):
import app.gateway.app as app_module
import deerflow.extensions as extensions
from deerflow.config.app_config import AppConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.extensions.registry import ExtensionRegistry
monkeypatch.setattr(app_module, "get_app_config", lambda: AppConfig(sandbox=SandboxConfig(use="test")))
monkeypatch.setattr("app.gateway.auth_middleware.is_auth_disabled", lambda: False)
async def authenticate(request):
return SimpleNamespace(id=request.cookies["access_token"], system_role="admin")
async def permissions(user, **kwargs):
return [] if user.id == "denied" else ["runs:read"]
monkeypatch.setattr("app.gateway.deps.get_current_user_from_request", authenticate)
monkeypatch.setattr("app.gateway.auth_middleware.resolve_route_permissions", permissions)
router = APIRouter()
@router.get("/api/evidence-test")
async def evidence(request: Request, thread_id: str = "thread-a", run_id: str = "run-a", cursor: str | None = None):
try:
reader = require_run_evidence_reader(request)
await asyncio.sleep(0)
status = await reader.get_run_status(thread_id=thread_id, run_id=run_id)
events = await reader.list_run_events(thread_id=thread_id, run_id=run_id, after_seq=None, limit=10)
changed = await reader.list_changed_runs(cursor=cursor, limit=10)
return {"status": asdict(status) if status else None, "events": asdict(events), "changed": asdict(changed)}
except PermissionError as exc:
raise HTTPException(403, str(exc)) from exc
except NotImplementedError as exc:
raise HTTPException(503, str(exc)) from exc
except InvalidRunEvidenceCursor as exc:
raise HTTPException(400, str(exc)) from exc
registry = ExtensionRegistry()
with registry.attributed_to("evidence:install"):
registry.routers((router,))
monkeypatch.setattr(extensions, "load_extensions", lambda plugins: (registry.build(), []))
app = app_module.create_app()
app.state.run_store = MemoryRunStore()
app.state.run_event_store = MemoryRunEventStore()
app.state.run_evidence_reader_factory = StoreRunEvidenceReaderFactory(app.state.run_store, app.state.run_event_store)
yield app
extensions.reset_loaded_extensions()
extensions.reset_runtime_diagnostics()
@pytest.mark.parametrize("state", [SimpleNamespace(), SimpleNamespace(user=SimpleNamespace(id="a", system_role="admin"))])
def test_resolver_rejects_missing_auth_context(evidence_app, state):
request = SimpleNamespace(app=evidence_app, state=state)
with pytest.raises(PermissionError):
require_run_evidence_reader(request)
def test_resolver_failure_does_not_return_a_global_reader():
def fail(request):
raise RuntimeError("resolver failed")
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(deerflow_extension_run_evidence_reader_resolver=fail)))
with pytest.raises(RuntimeError, match="resolver failed"):
resolve_run_evidence_reader(request)
@pytest.mark.asyncio
async def test_route_scope_cursor_and_concurrent_requests(evidence_app):
app = evidence_app
for owner in ("a", "b"):
await app.state.run_store.put(f"run-{owner}", thread_id=f"thread-{owner}", user_id=owner)
await app.state.run_event_store.put(thread_id=f"thread-{owner}", run_id=f"run-{owner}", event_type="test", category="message", content=owner)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
async def read(owner, **params):
return await client.get("/api/evidence-test", headers={"cookie": f"access_token={owner}"}, params=params)
a, b = await asyncio.gather(read("a", user_id="b"), read("b"))
assert a.status_code == b.status_code == 200
assert a.json()["status"]["run_id"] == "run-a"
assert a.json()["events"]["items"][0]["content"] == "a"
assert [item["run_id"] for item in b.json()["changed"]["items"]] == ["run-b"]
assert b.json()["status"] is None
assert b.json()["events"]["items"] == []
missing = await read("b", run_id="missing")
assert missing.json() == b.json()
assert (await read("b", cursor=a.json()["changed"]["next_cursor"])).status_code == 400
assert (await read("denied")).status_code == 403
assert (await client.get("/api/evidence-test")).status_code == 401
del app.state.run_evidence_reader_factory
assert (await read("a")).status_code == 503
global_reader = StoreRunEvidenceReader(app.state.run_store, app.state.run_event_store)
assert len((await global_reader.list_changed_runs(cursor=None, limit=10)).items) == 2