deer-flow/backend/tests/test_projects_router.py
Zeren Wang 5951c89b5b
feat(projects): project workspaces with scoped chats and thread membership (#5265)
* feat(projects): project workspaces with scoped chats and thread membership

Backend:
- projects table model and migration; fail-closed ProjectRepository with
  ownership checks, CRUD/archive/restore/delete router, and atomic thread
  move between projects
- threads_meta.project_id column exposed as reserved deerflow_project_id
  metadata; project-aware thread create/search with pagination bounds and
  membership echoed in create responses
- first-run admission assigns the project only at genuine first run, seeded
  at write time and dropped when invalid; serialized against project
  deletion and thread assignment
- branch creation inherits the source thread's project membership (an
  archived/deleted project degrades the branch to unassigned instead of
  failing the request)

Frontend:
- projects data layer, thread move API, and sidebar projects section with
  flat/grouped modes, archived-project threads, and stable virtual-list
  offsets
- project detail page with project-scoped new chat
  (/workspace/chats/new?project=) and paginated thread list
- move-to-project thread menu, new-project dialog, archived-project gates
- project-scoped new chats pre-create the thread with membership before the
  first submit or /goal set, so runs never proceed outside the project
- goal-set preparation is fenced against conversation switches: a stale
  continuation is dropped instead of saving the goal or launching the
  abandoned submission on the newly opened conversation
- project thread lists join thread lifecycle invalidations (stop, pin) so
  an open project page never keeps stale titles, recency, or pagination

* fix(chats): keep archive undo toast when the sidebar row unmounts

The archive success toast was fired from per-mutate callbacks passed to
mutation.mutate. React Query drops those handlers when the observer
component unmounts before the mutation settles; archiving the open chat
removes its sidebar row mid-flight, so the undo toast never appeared and
the e2e archive-undo test timed out waiting for it.

Move the success/error handlers to the mutation level (useArchiveThread
options, same pattern as useMoveThreadToProject) where callbacks are
delivered even after the originating row unmounts.

* fix(projects): pin project thread listing contract and exclude archived chats

GET /api/projects/{id}/threads returned the thread store row verbatim
(list[dict], no response_model): user_id/assistant_id leaked, any future
ThreadMetaRow column would auto-leak, and the OpenAPI schema was empty.
Return a narrow ProjectThreadResponse (the exact fields ProjectThread
declares) with the same metadata secret redaction the surrounding thread
endpoints get from _MetadataRedactingResponse.

The listing also ran search() without the archived filter, so a retired
chat rendered as a normal row on the project page while the sidebar hid
it. Search archived=False to mirror the sidebar's archived:false lists;
restore stays on the global Archived tab.

Both regressions pinned by new router tests: wire-shape allowlist and
archived-member exclusion.

* docs(migrations): record the 0019/0020 chain against the bootstrap reservation

The tree now chains 0018 -> 0019_projects -> 0020_threads_meta_project_id,
so migrations/AGENTS.md was stale twice over: the revision index stopped at
0018 and the rolling-forward section still claimed the tree 'deliberately
remains at 0018'.

Document the new head and record the intentional numeric-prefix reuse of
0019: 0019_projects is in-chain while 0019_thread_incarnations stays the
reserved, allowlisted out-of-tree rollout id. The owning rollout revision
must re-parent onto this tree's head when it merges so alembic never sees
two heads off 0018; bootstrap.py now cross-references that note next to
_FORWARD_COMPATIBLE_REVISION.

* fix(chats): invalidate project thread lists on archive/restore

useArchiveThread refreshed the infinite sidebar cache, threads/search and
the per-thread metadata cache but not the project-scoped list
([...PROJECTS_QUERY_KEY, 'threads', id]) this PR adds — the one thread
mutation not wired to that key, after usePinThread, useRenameThread,
useDeleteThread, useMoveThreadToProject and invalidateStoppedThreadCaches.

An archive from a sidebar row while a project page is open therefore left
the archived chat rendered as a normal row until remount (and undo left it
missing). Invalidate the prefix in the mutation-level success handler.

Regression test asserts the project-list prefix is invalidated on success.

* fix(projects): fetch project discovery only in grouped sidebar mode

RecentChatList mounted two useProjects queries per sidebar render, but
knownProjectIds is consumed only by the grouped-mode exclusion filter; in
the default flat mode every page load paid two GET /api/projects?status=
round trips for data nothing read. Gate both queries on grouped mode —
GroupedProjectList fetches the same keys when the toggle is on and
TanStack dedupes the observers.

Also set retry: false on useProject: a deleted or foreign project 404s
deterministically, and the page renders a dedicated not-found state for
it, so the default 1s/2s/4s retry backoff kept deep links in 'loading'
for ~7s before that state appeared. Matches useThreadMetadata /
useThreadTokenUsage.

* fix(threads): fail closed on project-scoped create in memory mode

MemoryThreadMetaStore.create accepted project_id and silently ignored it,
making memory mode the one membership path that fails open: POST
/api/threads with a project id returned 200 and the run started
unassigned, violating the invariant that a run never proceeds outside the
selected project (the SQL store raises ProjectNotAssignableError inside
the insert transaction for the same request).

Raise ProjectNotAssignableError whenever project_id is present so the
router's existing 404 mapping applies, the frontend keeps the composer
text for a retry, and memory mode behaves exactly like SQL mode.
set_project already reports rejection; create now matches it.

Store-level test (raises, nothing persisted, project filter stays empty,
unscoped creates still work) plus a router-level test asserting the 404
and that no row is left behind.

* fix(projects): window the project page thread list

ProjectThreadsSection rendered every loaded page as a plain Link row, so a
long-lived project accumulated unbounded DOM on the page's scroll surface:
each load-more appended another 100 rows and every formatTimeAgo tick
re-rendered the whole list.

Reuse VirtualThreadList (now generic over any row shape with a
thread_id), pointing its scroll parent at this page's ScrollArea viewport
via the shared [data-slot="scroll-area-viewport"] selector used by
/workspace/chats; under the 60-row threshold it falls back to the plain
render, so small projects are unchanged.

* fix(projects): restore row dividers and pin them with a render test

The row class template literal concatenated transition-colors directly
with the conditional border-b token, so non-final rows rendered the
invalid class 'transition-colorsborder-b' and lost both the divider and
the transition. Compose the row classes with cn() and a boolean guard
instead.

The section moved out of page.tsx into a testable component so the row
markup finally has coverage: a DOM test asserts every row except the
final data row carries border-b (index-based, not last: — correct under
virtualization where the last mounted row is not the last data row), and
the untitled fallback plus load-more button render for a partial page.

* fix(projects): validate forward schemas and fence membership reads
2026-09-08 17:00:26 +08:00

356 lines
15 KiB
Python

"""Router tests for the projects CRUD API (Phase 1).
Harness mirrors ``test_channel_connections_router.py`` (real SQLAlchemy repo
on a temp sqlite engine + TestClient) and ``_router_auth_helpers`` (stub auth
middleware), extended to also set the request-scoped user ContextVar that
``ProjectRepository`` / ``ThreadMetaRepository`` resolve ownership from, and
to take the user id from a header so cross-user isolation can be exercised.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import anyio
import pytest
from fastapi import FastAPI, Request, Response
from fastapi.testclient import TestClient
from starlette.middleware.base import BaseHTTPMiddleware
from app.gateway.authz import AuthContext, Permissions
from app.gateway.routers import projects
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.persistence.projects import ProjectRepository
from deerflow.persistence.thread_meta import THREAD_ARCHIVED_METADATA_KEY, THREAD_PROJECT_METADATA_KEY, ThreadMetaRepository
from deerflow.runtime.user_context import reset_current_user, set_current_user
_STUB_PERMISSIONS: list[str] = [
Permissions.THREADS_READ,
Permissions.THREADS_WRITE,
Permissions.THREADS_DELETE,
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
]
_USER_HEADER = "x-test-user"
_PERMISSIONS_HEADER = "x-test-permissions"
class _StubAuthMiddleware(BaseHTTPMiddleware):
"""Stamp a fake AuthContext and set the user ContextVar per request.
Mirrors production ``AuthMiddleware`` (``request.state.auth`` +
``set_current_user``) so ``@require_permission`` and the
ContextVar-resolving repositories behave as in the real gateway.
The user id comes from the ``x-test-user`` header (default ``user-a``) so a
single app can drive multiple identities. The granted permissions come from
the ``x-test-permissions`` header (comma-separated; default the full stub
list) so scope-narrowed callers can be exercised.
"""
async def dispatch(self, request: Request, call_next: Any) -> Response:
user_id = request.headers.get(_USER_HEADER, "user-a")
user = SimpleNamespace(id=user_id, system_role="user")
request.state.user = user
permissions_header = request.headers.get(_PERMISSIONS_HEADER)
permissions = permissions_header.split(",") if permissions_header else list(_STUB_PERMISSIONS)
request.state.auth = AuthContext(user=user, permissions=permissions)
token = set_current_user(user)
try:
return await call_next(request)
finally:
reset_current_user(token)
async def _init_db(tmp_path) -> None:
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'projects.db'}", sqlite_dir=str(tmp_path))
def _build_projects_app(tmp_path, *, project_repo: Any = "default") -> FastAPI:
"""Build a stub-authed FastAPI app with real SQL project/thread repos."""
anyio.run(_init_db, tmp_path)
sf = get_session_factory()
app = FastAPI()
app.add_middleware(_StubAuthMiddleware)
app.state.project_repo = ProjectRepository(sf) if project_repo == "default" else project_repo
app.state.thread_store = ThreadMetaRepository(sf)
app.include_router(projects.router)
return app
def _as_user(user_id: str) -> dict[str, str]:
return {_USER_HEADER: user_id}
def _seed_thread(app: FastAPI, thread_id: str, *, user_id: str, project_id: str | None = None, metadata: dict | None = None) -> dict:
"""Create a thread row directly through the store as ``user_id``."""
async def _run() -> dict:
token = set_current_user(SimpleNamespace(id=user_id))
try:
return await app.state.thread_store.create(thread_id, project_id=project_id, metadata=metadata)
finally:
reset_current_user(token)
return anyio.run(_run)
def _search_threads(app: FastAPI, *, user_id: str, **kwargs: Any) -> list[dict]:
async def _run() -> list[dict]:
token = set_current_user(SimpleNamespace(id=user_id))
try:
return await app.state.thread_store.search(**kwargs)
finally:
reset_current_user(token)
return anyio.run(_run)
@pytest.fixture(autouse=True)
def _close_engine_after_test():
yield
anyio.run(close_engine)
def test_create_list_get_project(tmp_path):
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
created = client.post("/api/projects", json={"name": "Infra overhaul", "instructions": "ctx"})
assert created.status_code == 201, created.text
project = created.json()
assert project["name"] == "Infra overhaul"
assert project["instructions"] == "ctx"
assert project["status"] == "active"
assert project["presentation"] == {}
listing = client.get("/api/projects")
assert listing.status_code == 200
assert [p["id"] for p in listing.json()["projects"]] == [project["id"]]
fetched = client.get(f"/api/projects/{project['id']}")
assert fetched.status_code == 200
assert fetched.json()["id"] == project["id"]
def test_get_patch_delete_foreign_project_returns_404(tmp_path):
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "mine"}).json()
# Fail closed: a foreign project is indistinguishable from a missing one.
assert client.get(f"/api/projects/{project['id']}", headers=_as_user("user-b")).status_code == 404
assert client.patch(f"/api/projects/{project['id']}", json={"name": "x"}, headers=_as_user("user-b")).status_code == 404
assert client.delete(f"/api/projects/{project['id']}", headers=_as_user("user-b")).status_code == 404
# Owner is unaffected.
assert client.get(f"/api/projects/{project['id']}").status_code == 200
assert client.get("/api/projects", headers=_as_user("user-b")).json()["projects"] == []
def test_patch_rename_does_not_touch_membership(tmp_path):
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "old"}).json()
_seed_thread(app, "thread-1", user_id="user-a", project_id=project["id"])
patched = client.patch(f"/api/projects/{project['id']}", json={"name": "new"})
assert patched.status_code == 200
assert patched.json()["name"] == "new"
members = _search_threads(app, user_id="user-a", project_id=project["id"])
assert [t["thread_id"] for t in members] == ["thread-1"]
def test_archive_restore_idempotent(tmp_path):
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "p"}).json()
pid = project["id"]
for _ in range(2):
archived = client.post(f"/api/projects/{pid}/archive")
assert archived.status_code == 200
assert archived.json()["status"] == "archived"
assert client.get("/api/projects", params={"status": "active"}).json()["projects"] == []
assert [p["id"] for p in client.get("/api/projects", params={"status": "archived"}).json()["projects"]] == [pid]
for _ in range(2):
restored = client.post(f"/api/projects/{pid}/restore")
assert restored.status_code == 200
assert restored.json()["status"] == "active"
assert [p["id"] for p in client.get("/api/projects").json()["projects"]] == [pid]
def test_delete_unlinks_threads_and_keeps_them(tmp_path):
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "p"}).json()
pid = project["id"]
_seed_thread(app, "thread-1", user_id="user-a", project_id=pid)
deleted = client.delete(f"/api/projects/{pid}")
assert deleted.status_code == 204
assert client.get(f"/api/projects/{pid}").status_code == 404
# Thread row survives with membership cleared.
threads = _search_threads(app, user_id="user-a")
assert [t["thread_id"] for t in threads] == ["thread-1"]
assert THREAD_PROJECT_METADATA_KEY not in threads[0]["metadata"]
assert _search_threads(app, user_id="user-a", project_id=pid) == []
def test_project_threads_lists_members_only(tmp_path):
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
p1 = client.post("/api/projects", json={"name": "p1"}).json()
p2 = client.post("/api/projects", json={"name": "p2"}).json()
_seed_thread(app, "thread-p1", user_id="user-a", project_id=p1["id"])
_seed_thread(app, "thread-p2", user_id="user-a", project_id=p2["id"])
_seed_thread(app, "thread-loose", user_id="user-a")
response = client.get(f"/api/projects/{p1['id']}/threads")
assert response.status_code == 200
assert [t["thread_id"] for t in response.json()] == ["thread-p1"]
# Unknown project -> 404, not an empty list.
assert client.get("/api/projects/nope/threads").status_code == 404
@pytest.mark.parametrize(
"params",
[
{"limit": -1},
{"limit": 0},
{"limit": 1001},
{"offset": -1},
],
)
def test_project_threads_rejects_out_of_bounds_pagination(tmp_path, params):
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "p"}).json()
response = client.get(f"/api/projects/{project['id']}/threads", params=params)
assert response.status_code == 422
@pytest.mark.parametrize("params", [{"limit": 1, "offset": 0}, {"limit": 1000}])
def test_project_threads_accepts_boundary_pagination(tmp_path, params):
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "p"}).json()
_seed_thread(app, "thread-1", user_id="user-a", project_id=project["id"])
response = client.get(f"/api/projects/{project['id']}/threads", params=params)
assert response.status_code == 200
assert [t["thread_id"] for t in response.json()] == ["thread-1"]
def test_project_threads_requires_threads_read(tmp_path):
"""The listing returns thread records (titles/metadata), so it must require
``threads:read`` alongside ``projects:read`` — matching
``/api/threads/search``. A caller holding only ``projects:read`` (e.g. a
scoped PAT) must be denied."""
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "p"}).json()
pid = project["id"]
_seed_thread(app, "thread-1", user_id="user-a", project_id=pid)
without_threads_read = {
**_as_user("user-a"),
_PERMISSIONS_HEADER: ",".join(p for p in _STUB_PERMISSIONS if p != Permissions.THREADS_READ),
}
denied = client.get(f"/api/projects/{pid}/threads", headers=without_threads_read)
assert denied.status_code == 403
allowed = client.get(f"/api/projects/{pid}/threads", headers=_as_user("user-a"))
assert allowed.status_code == 200
assert [t["thread_id"] for t in allowed.json()] == ["thread-1"]
def test_project_threads_redacts_legacy_auth_token(tmp_path):
"""Legacy ``auth_token`` metadata must not leak through the project thread
listing — thread endpoints already redact it via
``_MetadataRedactingResponse``; this listing must match."""
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "p"}).json()
pid = project["id"]
_seed_thread(app, "thread-1", user_id="user-a", project_id=pid, metadata={"auth_token": "sk-legacy-secret", "keep": "x"})
response = client.get(f"/api/projects/{pid}/threads")
assert response.status_code == 200
metadata = response.json()[0]["metadata"]
assert "auth_token" not in metadata
assert metadata["keep"] == "x"
assert metadata[THREAD_PROJECT_METADATA_KEY] == pid
# Redaction is response-side only; the stored row keeps the legacy key.
stored = _search_threads(app, user_id="user-a", project_id=pid)
assert stored[0]["metadata"]["auth_token"] == "sk-legacy-secret"
def test_project_threads_metadata_unchanged_without_legacy_key(tmp_path):
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "p"}).json()
pid = project["id"]
_seed_thread(app, "thread-1", user_id="user-a", project_id=pid, metadata={"keep": "x"})
response = client.get(f"/api/projects/{pid}/threads")
assert response.status_code == 200
assert response.json()[0]["metadata"] == {"keep": "x", THREAD_PROJECT_METADATA_KEY: pid}
def test_memory_backend_unavailable(tmp_path):
app = _build_projects_app(tmp_path, project_repo=None)
with TestClient(app) as client:
assert client.get("/api/projects").status_code == 503
assert client.post("/api/projects", json={"name": "p"}).status_code == 503
def test_project_threads_wire_shape_is_narrow(tmp_path):
"""The listing must not leak store-row internals: ownership columns
(``user_id``/``assistant_id``) and any future ``ThreadMetaRow`` column
stay off the wire, and the OpenAPI schema is no longer empty. The model
pins exactly the fields ``ProjectThread`` declares."""
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "p"}).json()
pid = project["id"]
_seed_thread(app, "thread-1", user_id="user-a", project_id=pid, metadata={"keep": "x"})
response = client.get(f"/api/projects/{pid}/threads")
assert response.status_code == 200
rows = response.json()
assert len(rows) == 1
row = rows[0]
assert set(row) == {"thread_id", "display_name", "created_at", "updated_at", "metadata"}
assert row["thread_id"] == "thread-1"
assert row["metadata"] == {"keep": "x", THREAD_PROJECT_METADATA_KEY: pid}
def test_project_threads_excludes_archived_members(tmp_path):
"""Archived chats leave the project listing the same way they leave the
sidebar (``archived: false`` semantics): no silent normal-row rendering
of a retired chat on the project page. The store keeps the row; restore
flows through the global Archived tab as elsewhere."""
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "p"}).json()
pid = project["id"]
_seed_thread(app, "active-1", user_id="user-a", project_id=pid)
_seed_thread(app, "archived-1", user_id="user-a", project_id=pid, metadata={THREAD_ARCHIVED_METADATA_KEY: True})
response = client.get(f"/api/projects/{pid}/threads")
assert response.status_code == 200
assert [t["thread_id"] for t in response.json()] == ["active-1"]
# The archived row still exists in the store (unfiltered search).
stored = _search_threads(app, user_id="user-a", project_id=pid)
assert {t["thread_id"] for t in stored} == {"active-1", "archived-1"}