deer-flow/backend/tests/test_conversation_access.py
Totoro 6177b07c06
fix(conversation): clarify reference semantics and keep reader pages inline (#5421)
Document that read permission expiry and source deletion do not erase
text already copied into the destination conversation, and that reads
follow the source's current visible history. Truncated results now tell
the agent to acknowledge the omission and ask for the missing material
before claiming every requirement is covered.

Pages were filled to 20,000 text characters by cutting the last message
that did not fit, and that suffix could never be paged back. They could
also exceed the default 12,000-character tool-output budget, which
externalized the page to a file. Pages are now sized by their serialized
length against the read_conversation tool-output budget; a message that
does not fit starts the next page intact, so only a message over 4,000
characters (or one whose escaped JSON alone exceeds the budget) is cut.

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 15:05:30 +08:00

218 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, tool_output=None):
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 [],
**({"tool_output": tool_output} if tool_output is not None 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()