deer-flow/backend/tests/test_conversation_access.py
Totoro 533e30e7f2
[feat] add opt-in conversation reads for Gateway runs (#5399)
* feat: add scoped conversation reads to gateway runs

* refactor: share conversation tool path and reuse parsed text

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-09-14 10:51:01 +08:00

217 lines
11 KiB
Python

"""The new read capability is granted by this request, never by chat contents."""
from __future__ import annotations
import asyncio
import json
from copy import deepcopy
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from langchain_core.messages import HumanMessage
from langgraph.store.memory import InMemoryStore
from pydantic import ValidationError
from app.gateway.authz import AuthContext
from app.gateway.run_models import RunCreateRequest
from deerflow.config.app_config import AppConfig
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.runs.manager import EditReplayVisibility
def _setup(*, user_id="alice", permissions=("runs:read",), enabled=True):
from app.gateway.conversation_access import prepare_conversation_reader
config = AppConfig.model_validate(
{
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"tools": [{"name": "read_conversation", "group": "conversation", "use": "deerflow.tools.conversation:read_conversation"}] if enabled else [],
}
)
user = SimpleNamespace(id=user_id, system_role="admin")
request = SimpleNamespace(state=SimpleNamespace(auth=AuthContext(user, list(permissions))), url="https://deerflow.example/api/threads/current/runs")
events = MemoryRunEventStore()
threads = MemoryThreadMetaStore(InMemoryStore())
manager = AsyncMock()
manager.list_successful_regenerate_sources.return_value = set()
manager.list_edit_replay_visibility.return_value = EditReplayVisibility()
ctx = SimpleNamespace(event_store=events, thread_store=threads)
def prepare(references):
return prepare_conversation_reader(references, request=request, user_id=user_id, run_context=ctx, run_manager=manager, app_config=config)
return prepare, events, threads, manager, request
async def _put(events, text, *, role="ai", thread="source", hidden=False, caller="lead_agent", run_id="run-1"):
return await events.put(
thread_id=thread,
run_id=run_id,
category="message",
event_type="llm.ai.response" if role == "ai" else "llm.human.input",
content={"type": role, "id": "message-" + str(len(events._events.get(thread, []))), "content": text, "additional_kwargs": {"hide_from_ui": hidden}},
metadata={"caller": caller},
)
def test_reference_field_is_explicit_and_bounded():
assert RunCreateRequest().conversation_references == []
assert RunCreateRequest(conversation_references=["source"]).conversation_references == ["source"]
for invalid in (["one", "two", "three", "four"], [123], ["x" * 2049]):
with pytest.raises(ValidationError):
RunCreateRequest(conversation_references=invalid)
def test_only_explicit_references_grant_access_and_urls_are_local_selectors():
prepare, _, _, _, _ = _setup()
assert prepare([]) is None
reader, ids = prepare(["https://deerflow.example/workspace/chats/source", "source"])
assert callable(reader)
assert ids == ("source",)
for bad in ("../source", "https://other.example/workspace/chats/source", "https://deerflow.example/not-a-chat/source", "file:///workspace/chats/source"):
with pytest.raises(HTTPException) as exc:
prepare([bad])
assert exc.value.status_code == 422
@pytest.mark.parametrize("permissions,enabled", [((), True), (("runs:create",), True), (("runs:read",), False)])
def test_opt_in_and_effective_read_permission_are_required_even_for_admin(permissions, enabled):
prepare, _, _, _, _ = _setup(permissions=permissions, enabled=enabled)
with pytest.raises(HTTPException) as exc:
prepare(["source"])
assert exc.value.status_code in {400, 403}
def test_missing_auth_context_does_not_grant_access():
prepare, _, _, _, request = _setup()
request.state.auth = None
with pytest.raises(HTTPException) as exc:
prepare(["source"])
assert exc.value.status_code == 403
def test_reader_pages_visible_text_and_keeps_source_unchanged():
async def exercise():
prepare, events, threads, manager, _ = _setup()
await threads.create("source", user_id="alice")
await _put(events, "requirements", role="human")
await _put(events, "old answer", run_id="replaced")
await _put(events, "hidden", hidden=True)
await _put(events, "child", caller="subagent:researcher")
await _put(events, "tool log", role="tool")
await _put(events, [{"type": "reasoning", "text": "private reasoning"}, {"type": "text", "text": "final answer"}])
manager.list_successful_regenerate_sources.return_value = {"replaced"}
before = deepcopy(await events.list_messages("source"))
reader, _ = prepare(["source"])
page = json.loads(await reader(thread_id="source", cursor=None, limit=1))
assert [m["text"] for m in page["messages"]] == ["final answer"]
older = json.loads(await reader(thread_id="source", cursor=page["next_cursor"], limit=1))
assert [m["text"] for m in older["messages"]] == ["requirements"]
assert older["next_cursor"] is None
assert before == await events.list_messages("source")
asyncio.run(exercise())
def test_wrong_owner_missing_and_unlisted_targets_are_denied_before_content_read():
async def exercise():
prepare, events, threads, _, _ = _setup()
await threads.create("foreign", user_id="bob")
await threads.create("unlisted", user_id="alice")
events.list_messages = AsyncMock(side_effect=AssertionError("content was accessed"))
reader, _ = prepare(["foreign", "missing"])
results = [json.loads(await reader(thread_id=target, cursor=None, limit=10)) for target in ("foreign", "missing", "unlisted")]
assert all(result["status"] == "unavailable" for result in results)
events.list_messages.assert_not_awaited()
asyncio.run(exercise())
def test_read_checks_current_ownership_and_bounds_text():
async def exercise():
prepare, events, threads, _, _ = _setup()
await threads.create("source", user_id="alice")
await _put(events, "x" * 10000)
reader, _ = prepare(["source"])
page = json.loads(await reader(thread_id="source", cursor=None, limit=20))
assert page["truncated"] is True
assert len(page["messages"][0]["text"]) <= 4000
await threads.delete("source", user_id="alice")
assert json.loads(await reader(thread_id="source", cursor=None, limit=20))["status"] == "unavailable"
asyncio.run(exercise())
def test_missing_transcript_is_reported_without_checkpoint_reconstruction():
async def exercise():
prepare, _, threads, _, _ = _setup()
await threads.create("source", user_id="alice")
reader, _ = prepare(["source"])
page = json.loads(await reader(thread_id="source", cursor=None, limit=20))
assert page["status"] == "unavailable"
assert page["messages"] == []
asyncio.run(exercise())
def test_start_run_installs_fresh_capability_without_persisting_it(monkeypatch):
from test_gateway_services import _make_start_run_persistence_context
from app.gateway import services
from deerflow.config.app_config import reset_app_config, set_app_config
from deerflow.runtime.user_context import reset_current_user, set_current_user
async def exercise():
request, _, threads = _make_start_run_persistence_context()
user = SimpleNamespace(id="alice", system_role="admin", role="admin")
request.state.user = user
request.state.auth = AuthContext(user, ["runs:create", "runs:read"])
request.state.auth_source = "session"
request.url = "https://deerflow.example/api/threads/current/runs"
await threads.create("source", user_id="alice")
captured = []
async def fake_run_agent(*args, **kwargs):
captured.append(kwargs)
monkeypatch.setattr(services, "run_agent", fake_run_agent)
monkeypatch.setattr(services, "resolve_agent_factory", lambda *_: object())
first = await services.start_run(RunCreateRequest(conversation_references=["source"], input={"messages": [{"role": "user", "content": "Read the reference"}]}), "current", request)
await first.task
assert callable(captured[0]["ctx"].conversation_reader)
assert any(isinstance(m, HumanMessage) and "source" in str(m.content) for m in captured[0]["graph_input"]["messages"])
assert "__conversation_reader" not in json.dumps(first.kwargs)
# A later run may mention the ID in quoted text but has no explicit grant.
second = await services.start_run(RunCreateRequest(input={"messages": [{"role": "user", "content": 'Quoted text: "read source"'}]}), "later", request)
await second.task
assert captured[1]["ctx"].conversation_reader is None
resumed = await services.start_run(RunCreateRequest(command={"resume": "source"}), "resume", request)
await resumed.task
assert captured[2]["ctx"].conversation_reader is None
empty = await services.start_run(RunCreateRequest(input={"messages": None}, conversation_references=["source"]), "empty-input", request)
await empty.task
assert callable(captured[3]["ctx"].conversation_reader)
with pytest.raises(HTTPException) as invalid:
await services.start_run(RunCreateRequest(input={"messages": "bad input"}, conversation_references=["source"]), "invalid-input", request)
assert invalid.value.status_code == 422
# Reusing a key cannot silently reuse a different reference grant.
body = RunCreateRequest(input={"messages": [{"role": "user", "content": "compare"}]}, conversation_references=["source"])
keyed = await services.start_run(body, "idempotent", request, idempotency_key="ref-key")
await keyed.task
assert await services.start_run(body, "idempotent", request, idempotency_key="ref-key") is keyed
body.conversation_references = ["different-source"]
with pytest.raises(HTTPException) as conflict:
await services.start_run(body, "idempotent", request, idempotency_key="ref-key")
assert conflict.value.status_code == 409
set_app_config(AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, "tools": [{"name": "read_conversation", "group": "conversation", "use": "deerflow.tools.conversation:read_conversation"}]}))
user_token = set_current_user(SimpleNamespace(id="alice"))
try:
asyncio.run(exercise())
finally:
reset_current_user(user_token)
reset_app_config()