deer-flow/backend/tests/test_memory_thread_meta_isolation.py
Xinmin Zeng 943d148e5e
feat(threads): distinguish branched conversations (#4983)
* feat(threads): number branched conversation titles

* feat(frontend): show branch lineage in recent chats

* fix(threads): allocate unique branch suffixes

* fix(threads): preserve suffix and filter semantics
2026-08-25 08:29:18 +08:00

212 lines
6.5 KiB
Python

"""Owner isolation tests for MemoryThreadMetaStore.
Mirrors the SQL-backed tests in test_owner_isolation.py but exercises
the in-memory LangGraph Store backend used when database.backend=memory.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from langgraph.store.memory import InMemoryStore
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
from deerflow.runtime.user_context import reset_current_user, set_current_user
USER_A = SimpleNamespace(id="user-a", email="a@test.local")
USER_B = SimpleNamespace(id="user-b", email="b@test.local")
def _as_user(user):
class _Ctx:
def __enter__(self):
self._token = set_current_user(user)
return user
def __exit__(self, *exc):
reset_current_user(self._token)
return _Ctx()
@pytest.fixture
def store():
return MemoryThreadMetaStore(InMemoryStore())
@pytest.mark.anyio
@pytest.mark.no_auto_user
async def test_search_isolation(store):
"""search() returns only threads owned by the current user."""
with _as_user(USER_A):
await store.create("t-alpha", display_name="A's thread")
with _as_user(USER_B):
await store.create("t-beta", display_name="B's thread")
with _as_user(USER_A):
results = await store.search()
assert [r["thread_id"] for r in results] == ["t-alpha"]
with _as_user(USER_B):
results = await store.search()
assert [r["thread_id"] for r in results] == ["t-beta"]
@pytest.mark.anyio
@pytest.mark.no_auto_user
async def test_search_matches_nested_thread_metadata(store):
with _as_user(USER_A):
await store.create("root", metadata={})
await store.create("child", metadata={"branch_parent_thread_id": "root"})
await store.create("other", metadata={"branch_parent_thread_id": "elsewhere"})
results = await store.search(metadata={"branch_parent_thread_id": "root"})
assert [record["thread_id"] for record in results] == ["child"]
@pytest.mark.anyio
@pytest.mark.no_auto_user
async def test_search_metadata_preserves_json_type_and_presence_contract(store):
with _as_user(USER_A):
await store.create("missing", metadata={})
await store.create("null", metadata={"value": None})
await store.create("bool", metadata={"value": True})
await store.create("int", metadata={"value": 1})
await store.create("float", metadata={"value": 1.0})
null_hits = await store.search(metadata={"value": None})
bool_hits = await store.search(metadata={"value": True})
int_hits = await store.search(metadata={"value": 1})
float_hits = await store.search(metadata={"value": 1.0})
assert [record["thread_id"] for record in null_hits] == ["null"]
assert [record["thread_id"] for record in bool_hits] == ["bool"]
assert [record["thread_id"] for record in int_hits] == ["int"]
assert {record["thread_id"] for record in float_hits} == {"float", "int"}
@pytest.mark.anyio
@pytest.mark.no_auto_user
async def test_update_display_name_can_remove_stale_metadata(store):
with _as_user(USER_A):
await store.create(
"branch",
display_name="Original (2)",
metadata={"branch_title_sequence": 2, "keep": True},
)
await store.update_display_name(
"branch",
"Report Q4",
remove_metadata_keys=("branch_title_sequence",),
)
result = await store.get("branch")
assert result is not None
assert result["display_name"] == "Report Q4"
assert result["metadata"] == {"keep": True}
@pytest.mark.anyio
@pytest.mark.no_auto_user
async def test_get_isolation(store):
"""get() returns None for threads owned by another user."""
with _as_user(USER_A):
await store.create("t-alpha", display_name="A's thread")
with _as_user(USER_B):
assert await store.get("t-alpha") is None
with _as_user(USER_A):
result = await store.get("t-alpha")
assert result is not None
assert result["display_name"] == "A's thread"
@pytest.mark.anyio
@pytest.mark.no_auto_user
async def test_update_display_name_denied(store):
"""User B cannot rename User A's thread."""
with _as_user(USER_A):
await store.create("t-alpha", display_name="original")
with _as_user(USER_B):
await store.update_display_name("t-alpha", "hacked")
with _as_user(USER_A):
row = await store.get("t-alpha")
assert row is not None
assert row["display_name"] == "original"
@pytest.mark.anyio
@pytest.mark.no_auto_user
async def test_update_status_denied(store):
"""User B cannot change status of User A's thread."""
with _as_user(USER_A):
await store.create("t-alpha")
with _as_user(USER_B):
await store.update_status("t-alpha", "error")
with _as_user(USER_A):
row = await store.get("t-alpha")
assert row is not None
assert row["status"] == "idle"
@pytest.mark.anyio
@pytest.mark.no_auto_user
async def test_update_metadata_denied(store):
"""User B cannot modify metadata of User A's thread."""
with _as_user(USER_A):
await store.create("t-alpha", metadata={"key": "original"})
with _as_user(USER_B):
await store.update_metadata("t-alpha", {"key": "hacked"})
with _as_user(USER_A):
row = await store.get("t-alpha")
assert row is not None
assert row["metadata"]["key"] == "original"
@pytest.mark.anyio
@pytest.mark.no_auto_user
async def test_delete_denied(store):
"""User B cannot delete User A's thread."""
with _as_user(USER_A):
await store.create("t-alpha")
with _as_user(USER_B):
await store.delete("t-alpha")
with _as_user(USER_A):
row = await store.get("t-alpha")
assert row is not None
@pytest.mark.anyio
@pytest.mark.no_auto_user
async def test_no_context_raises(store):
"""Calling methods without user context raises RuntimeError."""
with pytest.raises(RuntimeError, match="no user context is set"):
await store.search()
@pytest.mark.anyio
@pytest.mark.no_auto_user
async def test_explicit_none_bypasses_filter(store):
"""user_id=None bypasses isolation (migration/CLI escape hatch)."""
with _as_user(USER_A):
await store.create("t-alpha")
with _as_user(USER_B):
await store.create("t-beta")
all_rows = await store.search(user_id=None)
assert {r["thread_id"] for r in all_rows} == {"t-alpha", "t-beta"}
row = await store.get("t-alpha", user_id=None)
assert row is not None