mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
2169559d6e
* feat(projects): governed project containers — memory scope, grouping, manage UI
A workstream can attach to a project: a first-class, shareable resource
container that owns a `project` memory scope, groups conversations, and is
managed from the console.
Storage / migration 062: projects + project_members tables, workstreams.
project_id, and the memory type default project→general; grants
project.{create,read,write,delete} (admin-default).
Recall + writes: project memory is recalled iff the workstream is attached AND
the user has access (owner ∨ member ∨ public-for-read), resolved once at session
construction; coordinators recall it too. New saves default to the project when
attached + writable; the save and delete paths are write-gated; deleting a
project purges its scoped memory; archived projects aren't recalled.
Access = RBAC capability ∧ per-project ACL (auth.resolve_project_access, a
single-fetch resolver); visibility changes, member management, and delete are
owner-only.
API: project CRUD routes on both the server and console; project_id threaded
through workstream creation, spawn inheritance, the cluster-create proxy, the
dashboard / snapshot / coordinator row builders, and the collector deltas.
UI: a project picker with an inline "+ New project" creator in every creation
box (console launcher + standalone dialog + dashboard); group-by-project in the
rail; a project badge in the composer and on dashboard rows; a console manage
tab (list + create/edit + members shelves). The admin Memories view gains
coordinator/project scope filters and human scope labels (name, not hex). The
memory tool schema documents the project scope and the attach-aware default.
* fix(projects): client refresh hardening, creator race guard, SDK project_id
Addresses PR #724 review feedback plus two bugs found while validating it.
- projects.js refreshProjects: a non-OK status (e.g. 403 when the caller
lacks project.read) or a network/parse error no longer blanks the cache
or masquerades as "no projects" -- the prior cache is preserved, the
failure is recorded (new projectsError()) and warned. Honors the
long-standing "a transient error can't blank the rail" docstring.
- projects.js _fp: the fingerprint separators were raw control bytes,
which made git treat the whole file as binary (no reviewable diff).
Rewritten as escape sequences instead of raw bytes -- behavior is
byte-identical at runtime.
- project_creator.js: createProject() could reject unhandled (authFetch
throws on network/401; r.json() throws on a non-JSON body), leaving the
widget stuck busy/disabled. Added a .catch, plus a generation guard so a
create whose widget was cancelled/reopened mid-flight drops its result
instead of selecting a project the user backed out of.
- types.ts: add project_id to CreateWorkstreamRequest / WorkstreamInfo /
DashboardWorkstream to match the server schemas (was SDK-invisible).
- test_project_api.py: move side-effecting HTTP calls out of asserts so
the requests run even under python -O.
* fix(projects): JSON.stringify the cache fingerprint, drop control-byte separators
_fp joined fields/rows on raw NUL/SOH bytes, which made projects.js read as binary to git. Replace with a collision-proof, escape-free JSON.stringify encoding -- same change-detection semantics, zero embedded control characters.
197 lines
7.4 KiB
Python
197 lines
7.4 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,
|
|
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"],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
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
|