2fe0856e33
- Move all unit tests from tests/ to tests/unittest/ - Add tests/e2e/ directory for end-to-end tests - Update conftest.py for new test structure - Add new tests for auth dependencies, policies, route injection - Add new tests for run callbacks, create store, execution artifacts - Remove obsolete tests for deleted persistence layer Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
237 lines
8.6 KiB
Python
237 lines
8.6 KiB
Python
"""Cross-user isolation tests for current app-owned storage adapters.
|
|
|
|
These tests exercise isolation by binding different ``ActorContext``
|
|
values around the app-layer storage adapters. The safety property is:
|
|
|
|
data written under user A is not visible to user B through the same
|
|
adapter surface unless a call explicitly opts out with ``user_id=None``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import contextmanager
|
|
|
|
import pytest
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
|
|
from app.infra.storage import AppRunEventStore, FeedbackStoreAdapter, RunStoreAdapter, ThreadMetaStorage, ThreadMetaStoreAdapter
|
|
from deerflow.runtime.actor_context import AUTO, ActorContext, bind_actor_context, reset_actor_context
|
|
from store.persistence import MappedBase
|
|
|
|
|
|
USER_A = "user-a"
|
|
USER_B = "user-b"
|
|
|
|
|
|
async def _make_components(tmp_path):
|
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'isolation.db'}", future=True)
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(MappedBase.metadata.create_all)
|
|
|
|
session_factory = async_sessionmaker(
|
|
bind=engine,
|
|
class_=AsyncSession,
|
|
expire_on_commit=False,
|
|
autoflush=False,
|
|
)
|
|
|
|
thread_store = ThreadMetaStorage(ThreadMetaStoreAdapter(session_factory))
|
|
return (
|
|
engine,
|
|
thread_store,
|
|
RunStoreAdapter(session_factory),
|
|
FeedbackStoreAdapter(session_factory),
|
|
AppRunEventStore(session_factory),
|
|
)
|
|
|
|
|
|
@contextmanager
|
|
def _as_user(user_id: str):
|
|
token = bind_actor_context(ActorContext(user_id=user_id))
|
|
try:
|
|
yield
|
|
finally:
|
|
reset_actor_context(token)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.no_auto_user
|
|
async def test_thread_meta_cross_user_isolation(tmp_path):
|
|
engine, thread_store, _, _, _ = await _make_components(tmp_path)
|
|
try:
|
|
with _as_user(USER_A):
|
|
await thread_store.ensure_thread(thread_id="t-alpha")
|
|
with _as_user(USER_B):
|
|
await thread_store.ensure_thread(thread_id="t-beta")
|
|
|
|
with _as_user(USER_A):
|
|
assert (await thread_store.get_thread("t-alpha")) is not None
|
|
assert await thread_store.get_thread("t-beta") is None
|
|
rows = await thread_store.search_threads()
|
|
assert [row.thread_id for row in rows] == ["t-alpha"]
|
|
|
|
with _as_user(USER_B):
|
|
assert (await thread_store.get_thread("t-beta")) is not None
|
|
assert await thread_store.get_thread("t-alpha") is None
|
|
rows = await thread_store.search_threads()
|
|
assert [row.thread_id for row in rows] == ["t-beta"]
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.no_auto_user
|
|
async def test_runs_cross_user_isolation(tmp_path):
|
|
engine, thread_store, run_store, _, _ = await _make_components(tmp_path)
|
|
try:
|
|
with _as_user(USER_A):
|
|
await thread_store.ensure_thread(thread_id="t-alpha")
|
|
await run_store.create("run-a1", "t-alpha")
|
|
await run_store.create("run-a2", "t-alpha")
|
|
|
|
with _as_user(USER_B):
|
|
await thread_store.ensure_thread(thread_id="t-beta")
|
|
await run_store.create("run-b1", "t-beta")
|
|
|
|
with _as_user(USER_A):
|
|
assert (await run_store.get("run-a1")) is not None
|
|
assert await run_store.get("run-b1") is None
|
|
rows = await run_store.list_by_thread("t-alpha")
|
|
assert {row["run_id"] for row in rows} == {"run-a1", "run-a2"}
|
|
assert await run_store.list_by_thread("t-beta") == []
|
|
|
|
with _as_user(USER_B):
|
|
assert await run_store.get("run-a1") is None
|
|
rows = await run_store.list_by_thread("t-beta")
|
|
assert [row["run_id"] for row in rows] == ["run-b1"]
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.no_auto_user
|
|
async def test_run_events_cross_user_isolation(tmp_path):
|
|
engine, thread_store, _, _, event_store = await _make_components(tmp_path)
|
|
try:
|
|
with _as_user(USER_A):
|
|
await thread_store.ensure_thread(thread_id="t-alpha")
|
|
await event_store.put_batch(
|
|
[
|
|
{
|
|
"thread_id": "t-alpha",
|
|
"run_id": "run-a1",
|
|
"event_type": "human_message",
|
|
"category": "message",
|
|
"content": "User A private question",
|
|
},
|
|
{
|
|
"thread_id": "t-alpha",
|
|
"run_id": "run-a1",
|
|
"event_type": "ai_message",
|
|
"category": "message",
|
|
"content": "User A private answer",
|
|
},
|
|
]
|
|
)
|
|
|
|
with _as_user(USER_B):
|
|
await thread_store.ensure_thread(thread_id="t-beta")
|
|
await event_store.put_batch(
|
|
[
|
|
{
|
|
"thread_id": "t-beta",
|
|
"run_id": "run-b1",
|
|
"event_type": "human_message",
|
|
"category": "message",
|
|
"content": "User B private question",
|
|
}
|
|
]
|
|
)
|
|
|
|
with _as_user(USER_A):
|
|
msgs = await event_store.list_messages("t-alpha")
|
|
contents = [msg["content"] for msg in msgs]
|
|
assert "User A private question" in contents
|
|
assert "User A private answer" in contents
|
|
assert "User B private question" not in contents
|
|
assert await event_store.list_messages("t-beta") == []
|
|
assert await event_store.list_events("t-beta", "run-b1") == []
|
|
assert await event_store.count_messages("t-beta") == 0
|
|
|
|
with _as_user(USER_B):
|
|
msgs = await event_store.list_messages("t-beta")
|
|
contents = [msg["content"] for msg in msgs]
|
|
assert "User B private question" in contents
|
|
assert "User A private question" not in contents
|
|
assert await event_store.count_messages("t-alpha") == 0
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.no_auto_user
|
|
async def test_feedback_cross_user_isolation(tmp_path):
|
|
engine, thread_store, _, feedback_store, _ = await _make_components(tmp_path)
|
|
try:
|
|
with _as_user(USER_A):
|
|
await thread_store.ensure_thread(thread_id="t-alpha")
|
|
a_feedback = await feedback_store.create(
|
|
run_id="run-a1",
|
|
thread_id="t-alpha",
|
|
rating=1,
|
|
user_id=USER_A,
|
|
comment="A liked this",
|
|
)
|
|
|
|
with _as_user(USER_B):
|
|
await thread_store.ensure_thread(thread_id="t-beta")
|
|
b_feedback = await feedback_store.create(
|
|
run_id="run-b1",
|
|
thread_id="t-beta",
|
|
rating=-1,
|
|
user_id=USER_B,
|
|
comment="B disliked this",
|
|
)
|
|
|
|
with _as_user(USER_A):
|
|
assert (await feedback_store.get(a_feedback["feedback_id"])) is not None
|
|
assert await feedback_store.get(b_feedback["feedback_id"]) is not None
|
|
assert await feedback_store.list_by_run("t-beta", "run-b1", user_id=USER_A) == []
|
|
|
|
with _as_user(USER_B):
|
|
assert await feedback_store.list_by_run("t-alpha", "run-a1", user_id=USER_B) == []
|
|
rows = await feedback_store.list_by_run("t-beta", "run-b1", user_id=USER_B)
|
|
assert len(rows) == 1
|
|
assert rows[0]["comment"] == "B disliked this"
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.no_auto_user
|
|
async def test_repository_without_context_raises(tmp_path):
|
|
engine, thread_store, _, _, _ = await _make_components(tmp_path)
|
|
try:
|
|
with pytest.raises(RuntimeError, match="no actor context is set"):
|
|
await thread_store.search_threads(user_id=AUTO)
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.no_auto_user
|
|
async def test_explicit_none_bypasses_filter(tmp_path):
|
|
engine, thread_store, _, _, _ = await _make_components(tmp_path)
|
|
try:
|
|
with _as_user(USER_A):
|
|
await thread_store.ensure_thread(thread_id="t-alpha")
|
|
with _as_user(USER_B):
|
|
await thread_store.ensure_thread(thread_id="t-beta")
|
|
|
|
rows = await thread_store.search_threads(user_id=None)
|
|
assert {row.thread_id for row in rows} == {"t-alpha", "t-beta"}
|
|
assert await thread_store.get_thread("t-alpha", user_id=None) is not None
|
|
assert await thread_store.get_thread("t-beta", user_id=None) is not None
|
|
finally:
|
|
await engine.dispose()
|