mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 22:20:33 +00:00
* fix(gateway): return 404 on cross-owner thread_id instead of 500
When a caller-supplied thread_id already belongs to another user, the SQL-backed store rejects the duplicate primary key with IntegrityError. The route's recovery path resolved the surviving row through the requesting owner's scope, so a foreign row could not be read back and the route surfaced a 500 instead of the documented 404 ownership-conflict response ("Thread not found").
Read the row back bypassing the owner scope before collapsing to a 500 so the cross-owner case mirrors ThreadOwnershipConflictError.
Fix #8052
Signed-off-by: inchang-ing <197932532+inchang-ing@users.noreply.github.com>
* test(gateway): pin thread-ownership 404 and real-failure 500
Address review feedback on the cross-owner 404 recovery:
- Update test_insert_race_concurrent_trusted_claims_have_one_winner: the
losing request now resolves the surviving unscoped row as a 404 ownership
conflict instead of a 500, while the single-winner assertions and the
non-disclosing detail are preserved.
- Add test_create_thread_keeps_real_integrity_failure_as_500 to pin the
fall-through branch where a duplicate-key IntegrityError coincides with no
surviving row, which must still surface as a 500 rather than collapse into
the ownership-conflict 404.
- Document the user-facing error-surface change in the Unreleased Fixed
section.
Signed-off-by: inchang-ing <197932532+inchang-ing@users.noreply.github.com>
---------
Signed-off-by: inchang-ing <197932532+inchang-ing@users.noreply.github.com>
Co-authored-by: inchang-ing <197932532+inchang-ing@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
88 lines
3.1 KiB
Python
88 lines
3.1 KiB
Python
"""Cross-owner thread_id creation returns 404, never 500 (SQL backend).
|
|
|
|
Regression for issue #8052: when a caller-supplied ``thread_id`` already
|
|
belongs to another user, the SQL-backed store rejects the duplicate primary
|
|
key with ``IntegrityError`` (the memory store raises the app-level
|
|
``ThreadOwnershipConflictError`` instead). The route's recovery path resolved
|
|
the surviving row through the *requesting* owner's scope, so a foreign row
|
|
could not be read back and the route surfaced a 500 instead of the documented
|
|
404 ownership-conflict response ("Thread not found").
|
|
|
|
The memory store does not have this bug, so the regression is exercised on the
|
|
sqlite backend only.
|
|
"""
|
|
|
|
from uuid import UUID
|
|
|
|
import httpx
|
|
import pytest
|
|
from _router_auth_helpers import make_authed_test_app
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
|
|
from app.gateway.auth.models import User
|
|
from app.gateway.routers import threads
|
|
from deerflow.persistence.thread_meta import ThreadMetaRepository
|
|
|
|
USER_A = User(
|
|
id=UUID("00000000-0000-0000-0000-0000000000a1"),
|
|
email="owner-a@example.com",
|
|
password_hash="x",
|
|
system_role="admin",
|
|
)
|
|
USER_B = User(
|
|
id=UUID("00000000-0000-0000-0000-0000000000b2"),
|
|
email="owner-b@example.com",
|
|
password_hash="x",
|
|
system_role="admin",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def sql_thread_store(tmp_path):
|
|
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
|
|
|
|
await init_engine(
|
|
"sqlite",
|
|
url=f"sqlite+aiosqlite:///{tmp_path / 'threads.db'}",
|
|
sqlite_dir=str(tmp_path),
|
|
)
|
|
try:
|
|
yield ThreadMetaRepository(get_session_factory())
|
|
finally:
|
|
await close_engine()
|
|
|
|
|
|
def app_for(store, checkpointer, user):
|
|
app = make_authed_test_app(user_factory=lambda: user, bind_current_user=True)
|
|
app.state.thread_store = store
|
|
app.state.checkpointer = checkpointer
|
|
app.include_router(threads.router)
|
|
return app
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cross_owner_thread_id_returns_404(sql_thread_store):
|
|
saver = InMemorySaver()
|
|
app_a = app_for(sql_thread_store, saver, USER_A)
|
|
app_b = app_for(sql_thread_store, saver, USER_B)
|
|
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app_a), base_url="http://test") as client_a:
|
|
created = await client_a.post(
|
|
"/api/threads",
|
|
json={"thread_id": "shared-client-id", "metadata": {"marker": "owner-a"}},
|
|
)
|
|
assert created.status_code == 200
|
|
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app_b), base_url="http://test") as client_b:
|
|
conflict = await client_b.post(
|
|
"/api/threads",
|
|
json={"thread_id": "shared-client-id", "metadata": {"marker": "owner-b"}},
|
|
)
|
|
|
|
# The documented non-disclosing ownership-conflict response, not a 500.
|
|
assert conflict.status_code == 404
|
|
assert conflict.json()["detail"] == "Thread not found"
|
|
# A's metadata is untouched and B cannot read the foreign row.
|
|
assert await sql_thread_store.get("shared-client-id", user_id=str(USER_A.id)) is not None
|
|
assert await sql_thread_store.get("shared-client-id", user_id=str(USER_B.id)) is None
|