mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
bf9299de1a
Dashboard saved-sessions lists now carry and render the workstream's
project: SavedWorkstreamInfo gains project_id (the saved projection was
extended in the visibility change), SavedColumns grows a PROJECT column
(name resolved through the shared projects data layer, searchable via
the filter haystack, re-rendered when the async project cache fills),
inserted on both the webui saved-workstreams and console saved-sessions
tables.
Manage → governance → Projects rows are now expandable (same
interaction contract as the Users tab's OIDC panel): a per-project
resources panel lists the project's workstreams (kind/state/updated),
referenced attachments (metadata + ws-scoped download link through the
console's node proxy), and the project-scoped memory count. Backed by
GET /v1/api/projects/{id}/resources (project.read + per-project ACL,
collection off the event loop) over two new storage queries —
list_workstreams_for_project (first consumer of idx_workstreams_project)
and list_project_attachments (conversation ref-list walk, metadata only,
first-referencing ws per blob, pruned blobs skipped).
250 lines
10 KiB
Python
250 lines
10 KiB
Python
"""Tests for the project HTTP endpoints (server-side CRUD).
|
|
|
|
Exercises the owner happy-path through a Starlette TestClient with an auth
|
|
middleware that injects the ``project.*`` capabilities (the per-project ACL +
|
|
RBAC composition itself is unit-tested in ``test_project_storage.py``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import pytest
|
|
from starlette.applications import Starlette
|
|
from starlette.middleware import Middleware
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.routing import Mount, Route
|
|
from starlette.testclient import TestClient
|
|
|
|
from turnstone.core.auth import AuthResult
|
|
from turnstone.core.storage._sqlite import SQLiteBackend
|
|
from turnstone.server import (
|
|
add_project_member_endpoint,
|
|
create_project,
|
|
delete_project_endpoint,
|
|
get_project_endpoint,
|
|
list_project_members_endpoint,
|
|
list_projects,
|
|
project_resources_endpoint,
|
|
remove_project_member_endpoint,
|
|
update_project_endpoint,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response
|
|
|
|
_PERMS = frozenset(
|
|
{
|
|
"read",
|
|
"write",
|
|
"approve",
|
|
"project.create",
|
|
"project.read",
|
|
"project.write",
|
|
"project.delete",
|
|
}
|
|
)
|
|
|
|
|
|
class _InjectAuthMiddleware(BaseHTTPMiddleware):
|
|
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
|
request.state.auth_result = AuthResult(
|
|
user_id="alice",
|
|
scopes=frozenset({"approve"}),
|
|
token_source="config",
|
|
permissions=_PERMS,
|
|
)
|
|
response: Response = await call_next(request)
|
|
return response
|
|
|
|
|
|
@pytest.fixture
|
|
def storage(tmp_path: Path) -> SQLiteBackend:
|
|
return SQLiteBackend(str(tmp_path / "test.db"))
|
|
|
|
|
|
@pytest.fixture
|
|
def client(storage: SQLiteBackend) -> Iterator[TestClient]:
|
|
import turnstone.core.storage._registry as reg
|
|
|
|
old = reg._storage
|
|
reg._storage = storage
|
|
app = Starlette(
|
|
routes=[
|
|
Mount(
|
|
"/v1",
|
|
routes=[
|
|
Route("/api/projects", list_projects),
|
|
Route("/api/projects", create_project, methods=["POST"]),
|
|
Route("/api/projects/{project_id}", get_project_endpoint),
|
|
Route(
|
|
"/api/projects/{project_id}",
|
|
update_project_endpoint,
|
|
methods=["PATCH"],
|
|
),
|
|
Route(
|
|
"/api/projects/{project_id}",
|
|
delete_project_endpoint,
|
|
methods=["DELETE"],
|
|
),
|
|
Route(
|
|
"/api/projects/{project_id}/members",
|
|
list_project_members_endpoint,
|
|
),
|
|
Route(
|
|
"/api/projects/{project_id}/members",
|
|
add_project_member_endpoint,
|
|
methods=["POST"],
|
|
),
|
|
Route(
|
|
"/api/projects/{project_id}/members/{user_id}",
|
|
remove_project_member_endpoint,
|
|
methods=["DELETE"],
|
|
),
|
|
Route(
|
|
"/api/projects/{project_id}/resources",
|
|
project_resources_endpoint,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
yield TestClient(app)
|
|
reg._storage = old
|
|
|
|
|
|
class TestProjectApi:
|
|
def test_create_list_get(self, client: TestClient) -> None:
|
|
r = client.post("/v1/api/projects", json={"name": "Research"})
|
|
assert r.status_code == 201
|
|
pid = r.json()["project_id"]
|
|
assert r.json()["name"] == "Research"
|
|
assert r.json()["owner_id"] == "alice"
|
|
assert r.json()["visibility"] == "private"
|
|
|
|
r = client.get("/v1/api/projects")
|
|
assert r.status_code == 200
|
|
assert pid in {p["project_id"] for p in r.json()["projects"]}
|
|
|
|
r = client.get(f"/v1/api/projects/{pid}")
|
|
assert r.status_code == 200
|
|
assert r.json()["name"] == "Research"
|
|
|
|
def test_create_requires_name(self, client: TestClient) -> None:
|
|
r = client.post("/v1/api/projects", json={})
|
|
assert r.status_code == 400
|
|
|
|
def test_create_rejects_bad_visibility(self, client: TestClient) -> None:
|
|
r = client.post("/v1/api/projects", json={"name": "X", "visibility": "bogus"})
|
|
assert r.status_code == 400
|
|
|
|
def test_update_rename_and_archive(self, client: TestClient) -> None:
|
|
pid = client.post("/v1/api/projects", json={"name": "A"}).json()["project_id"]
|
|
r = client.patch(f"/v1/api/projects/{pid}", json={"name": "B", "state": "archived"})
|
|
assert r.status_code == 200
|
|
assert r.json()["name"] == "B"
|
|
assert r.json()["state"] == "archived"
|
|
# Archived projects drop out of the default list...
|
|
r = client.get("/v1/api/projects")
|
|
assert pid not in {p["project_id"] for p in r.json()["projects"]}
|
|
# ...but appear with include_archived.
|
|
r = client.get("/v1/api/projects?include_archived=1")
|
|
assert pid in {p["project_id"] for p in r.json()["projects"]}
|
|
|
|
def test_visibility_change_is_owner_only(
|
|
self, client: TestClient, storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# The ACL's capability check reads from storage (not the injected
|
|
# AuthResult); grant it so this test isolates the owner-vs-member gate.
|
|
from turnstone.core import auth
|
|
|
|
monkeypatch.setattr(auth, "user_has_permission", lambda *a, **k: True)
|
|
# Alice owns this one → she may flip visibility.
|
|
pid = client.post("/v1/api/projects", json={"name": "Mine"}).json()["project_id"]
|
|
r = client.patch(f"/v1/api/projects/{pid}", json={"visibility": "public"})
|
|
assert r.status_code == 200
|
|
assert r.json()["visibility"] == "public"
|
|
# Bob owns this one; alice is a write-tier member → may rename, but NOT
|
|
# flip visibility (a confidentiality lever the owner did not delegate).
|
|
storage.create_project("bobproj", "Bob's", "bob")
|
|
storage.add_project_member("bobproj", "alice")
|
|
r = client.patch("/v1/api/projects/bobproj", json={"name": "Renamed"})
|
|
assert r.status_code == 200
|
|
r = client.patch("/v1/api/projects/bobproj", json={"visibility": "public"})
|
|
assert r.status_code == 403
|
|
|
|
def test_members_add_list_remove(self, client: TestClient) -> None:
|
|
pid = client.post("/v1/api/projects", json={"name": "A"}).json()["project_id"]
|
|
r = client.post(f"/v1/api/projects/{pid}/members", json={"user_id": "bob"})
|
|
assert r.status_code == 200
|
|
assert "bob" in r.json()["members"]
|
|
r = client.get(f"/v1/api/projects/{pid}/members")
|
|
assert r.json()["members"] == ["bob"]
|
|
r = client.delete(f"/v1/api/projects/{pid}/members/bob")
|
|
assert r.status_code == 200
|
|
assert r.json()["members"] == []
|
|
|
|
def test_delete(self, client: TestClient) -> None:
|
|
pid = client.post("/v1/api/projects", json={"name": "A"}).json()["project_id"]
|
|
r = client.delete(f"/v1/api/projects/{pid}")
|
|
assert r.status_code == 200
|
|
r = client.get(f"/v1/api/projects/{pid}")
|
|
assert r.status_code == 404
|
|
|
|
def test_get_missing_404(self, client: TestClient) -> None:
|
|
r = client.get("/v1/api/projects/nope")
|
|
assert r.status_code == 404
|
|
|
|
|
|
class TestProjectResources:
|
|
def _seed(self, client: TestClient, storage: SQLiteBackend) -> str:
|
|
pid: str = client.post("/v1/api/projects", json={"name": "R"}).json()["project_id"]
|
|
storage.register_workstream("ws-a", name="alpha", user_id="alice", project_id=pid)
|
|
storage.register_workstream("ws-b", name="beta", user_id="alice", project_id=pid)
|
|
storage.register_workstream("ws-x", name="other", user_id="alice")
|
|
mid = storage.save_message("ws-a", "user", "see attached")
|
|
storage.save_attachment("a" * 64, "notes.txt", "text/plain", 5, "text", b"hello")
|
|
storage.set_message_attachments("ws-a", mid, ["a" * 64])
|
|
storage.create_structured_memory("m1", "fact", "d", "general", "project", pid, "body")
|
|
return pid
|
|
|
|
def test_resources_aggregate(self, client: TestClient, storage: SQLiteBackend) -> None:
|
|
pid = self._seed(client, storage)
|
|
r = client.get(f"/v1/api/projects/{pid}/resources")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["project_id"] == pid
|
|
assert body["name"] == "R"
|
|
ws_ids = [w["ws_id"] for w in body["workstreams"]]
|
|
assert set(ws_ids) == {"ws-a", "ws-b"} # ws-x is not in the project
|
|
atts = body["attachments"]
|
|
assert len(atts) == 1
|
|
assert atts[0]["attachment_id"] == "a" * 64
|
|
assert atts[0]["filename"] == "notes.txt"
|
|
assert atts[0]["ws_id"] == "ws-a"
|
|
assert "content" not in atts[0] # metadata only — never the blob
|
|
assert body["memory_count"] == 1
|
|
|
|
def test_resources_empty_project(self, client: TestClient) -> None:
|
|
pid = client.post("/v1/api/projects", json={"name": "E"}).json()["project_id"]
|
|
body = client.get(f"/v1/api/projects/{pid}/resources").json()
|
|
assert body["workstreams"] == []
|
|
assert body["attachments"] == []
|
|
assert body["memory_count"] == 0
|
|
|
|
def test_resources_missing_404(self, client: TestClient) -> None:
|
|
assert client.get("/v1/api/projects/nope/resources").status_code == 404
|
|
|
|
def test_resources_private_non_member_403(
|
|
self, client: TestClient, storage: SQLiteBackend
|
|
) -> None:
|
|
# Owned by someone else, private — alice holds project.read but no
|
|
# membership, so the per-project ACL denies.
|
|
storage.create_project("p-zed", "Z", "zed")
|
|
assert client.get("/v1/api/projects/p-zed/resources").status_code == 403
|