mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
a7f6fdf099
Bearer-authed platform admins (PAT/CLI/OAuth) who are restricted members of a workspace were unrestricted on dashboard, bootstrap, items, and graph reads (plus item creation) — the last remaining gap in the BUG-1616/1617 pattern, where RequireWorkspaceAccess already suppresses the admin bypass for bearer auth everywhere else. visibleCollectionIDs now applies the same `!isBearerAuth(r)` gate as reportVisibleCollections, so a bearer admin who is only a scoped member is correctly restricted to their membership; a cookie-session admin keeps the existing unrestricted web UI affordance. Because this is the shared helper, every consumer (buildDashboardResponse -> /dashboard and /bootstrap, handleListItems, handleGetWorkspaceGraph, handleCreateItem's collection check, and ~25 other call sites) is fixed at once. This also completes TASK-1894's known asymmetry: standup's blockers/suggested_next sections (sourced from buildDashboardResponse) are now scoped for bearer admins just like its completed/in_progress sections already were. Folds the now-redundant bearerAwareVisibleCollectionIDs into the shared gate. Claude-Session: https://claude.ai/code/session_01CL1pBjNpPUX6SWkuAuYXHS
553 lines
19 KiB
Go
553 lines
19 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// helper: fetch the workspace graph and return the parsed response
|
|
func getGraph(t *testing.T, srv *Server, wsSlug, query string) GraphResponse {
|
|
t.Helper()
|
|
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+wsSlug+"/graph"+query, nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("graph: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp GraphResponse
|
|
parseJSON(t, rr, &resp)
|
|
return resp
|
|
}
|
|
|
|
func graphNode(resp GraphResponse, ref string) *GraphNode {
|
|
for i := range resp.Nodes {
|
|
if resp.Nodes[i].Ref == ref {
|
|
return &resp.Nodes[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hasGraphEdge(resp GraphResponse, source, target, typ string) bool {
|
|
for _, e := range resp.Edges {
|
|
if e.Source == source && e.Target == target && e.Type == typ {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestGraphEmpty(t *testing.T) {
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
|
|
resp := getGraph(t, srv, slug, "")
|
|
if len(resp.Nodes) != 0 {
|
|
t.Errorf("expected 0 nodes, got %d", len(resp.Nodes))
|
|
}
|
|
if len(resp.Edges) != 0 {
|
|
t.Errorf("expected 0 edges, got %d", len(resp.Edges))
|
|
}
|
|
|
|
// Arrays must serialize as [], not null.
|
|
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/graph", nil)
|
|
body := rr.Body.String()
|
|
if body == "" || body[0] != '{' {
|
|
t.Fatalf("unexpected body: %s", body)
|
|
}
|
|
for _, frag := range []string{`"nodes":[]`, `"edges":[]`} {
|
|
if !strings.Contains(body, frag) {
|
|
t.Errorf("expected body to contain %s, got %s", frag, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGraphNodesAndTypedEdges(t *testing.T) {
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
|
|
plan := createItem(t, srv, slug, "plans", map[string]interface{}{
|
|
"title": "Graph plan", "fields": map[string]interface{}{"status": "active"},
|
|
})
|
|
task := createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Graph child task", "fields": map[string]interface{}{"status": "open"},
|
|
})
|
|
blocked := createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Blocked task", "fields": map[string]interface{}{"status": "open"},
|
|
})
|
|
|
|
// task is a child of plan
|
|
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+task.Slug+"/links", map[string]interface{}{
|
|
"target_id": plan.ID, "link_type": "parent",
|
|
})
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("create parent link: expected 201, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
// task blocks blocked
|
|
createBlocksLink(t, srv, slug, task.Slug, blocked.ID)
|
|
// blocked split from plan — stored as split_from, must surface
|
|
// hyphenated per the advertised edge vocabulary.
|
|
rr2 := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+blocked.Slug+"/links", map[string]interface{}{
|
|
"target_id": plan.ID, "link_type": "split_from",
|
|
})
|
|
if rr2.Code != http.StatusCreated {
|
|
t.Fatalf("create split_from link: expected 201, got %d: %s", rr2.Code, rr2.Body.String())
|
|
}
|
|
|
|
resp := getGraph(t, srv, slug, "")
|
|
if len(resp.Nodes) != 3 {
|
|
t.Fatalf("expected 3 nodes, got %d: %+v", len(resp.Nodes), resp.Nodes)
|
|
}
|
|
|
|
pn := graphNode(resp, plan.Ref)
|
|
if pn == nil {
|
|
t.Fatalf("plan node %s missing", plan.Ref)
|
|
}
|
|
if pn.Collection != "plans" {
|
|
t.Errorf("expected plan node collection=plans, got %s", pn.Collection)
|
|
}
|
|
if pn.Status != "active" {
|
|
t.Errorf("expected plan node status=active, got %s", pn.Status)
|
|
}
|
|
if pn.IsTerminal {
|
|
t.Errorf("expected plan node is_terminal=false")
|
|
}
|
|
if pn.ChildCount != 1 {
|
|
t.Errorf("expected plan child_count=1, got %d", pn.ChildCount)
|
|
}
|
|
tn := graphNode(resp, task.Ref)
|
|
if tn == nil || tn.ChildCount != 0 {
|
|
t.Errorf("expected task node with child_count=0, got %+v", tn)
|
|
}
|
|
|
|
if !hasGraphEdge(resp, task.Ref, plan.Ref, "parent") {
|
|
t.Errorf("expected parent edge %s -> %s, edges: %+v", task.Ref, plan.Ref, resp.Edges)
|
|
}
|
|
if !hasGraphEdge(resp, task.Ref, blocked.Ref, "blocks") {
|
|
t.Errorf("expected blocks edge %s -> %s, edges: %+v", task.Ref, blocked.Ref, resp.Edges)
|
|
}
|
|
if !hasGraphEdge(resp, blocked.Ref, plan.Ref, "split-from") {
|
|
t.Errorf("expected split-from edge %s -> %s, edges: %+v", blocked.Ref, plan.Ref, resp.Edges)
|
|
}
|
|
}
|
|
|
|
func TestGraphWikiLinkEdges(t *testing.T) {
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
|
|
target := createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Wiki target", "fields": map[string]interface{}{"status": "open"},
|
|
})
|
|
// Mention the target by ref twice — must still produce ONE edge.
|
|
source := createItem(t, srv, slug, "docs", map[string]interface{}{
|
|
"title": "Wiki source",
|
|
"content": "See [[" + target.Ref + "]] and again [[" + target.Ref + "]].",
|
|
})
|
|
// A stored wiki_link item_links row for the same pair normalizes to
|
|
// the same 'wiki-link' type and must dedupe with the parsed mention.
|
|
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+source.Slug+"/links", map[string]interface{}{
|
|
"target_id": target.ID, "link_type": "wiki_link",
|
|
})
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("create wiki_link link: expected 201, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
resp := getGraph(t, srv, slug, "")
|
|
count := 0
|
|
for _, e := range resp.Edges {
|
|
if e.Source == source.Ref && e.Target == target.Ref && e.Type == "wiki-link" {
|
|
count++
|
|
}
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected exactly 1 wiki-link edge %s -> %s, got %d (edges: %+v)",
|
|
source.Ref, target.Ref, count, resp.Edges)
|
|
}
|
|
}
|
|
|
|
func TestGraphTerminalFiltering(t *testing.T) {
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
|
|
open := createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Open task", "fields": map[string]interface{}{"status": "open"},
|
|
})
|
|
done := createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Done task", "fields": map[string]interface{}{"status": "done"},
|
|
})
|
|
// Edge between them — must disappear with the terminal node.
|
|
createBlocksLink(t, srv, slug, done.Slug, open.ID)
|
|
|
|
resp := getGraph(t, srv, slug, "")
|
|
if graphNode(resp, open.Ref) == nil {
|
|
t.Errorf("expected open task %s in default graph", open.Ref)
|
|
}
|
|
if graphNode(resp, done.Ref) != nil {
|
|
t.Errorf("expected done task %s filtered from default graph", done.Ref)
|
|
}
|
|
if len(resp.Edges) != 0 {
|
|
t.Errorf("expected edges to terminal nodes filtered, got %+v", resp.Edges)
|
|
}
|
|
|
|
full := getGraph(t, srv, slug, "?include_terminal=true")
|
|
dn := graphNode(full, done.Ref)
|
|
if dn == nil {
|
|
t.Fatalf("expected done task %s with include_terminal=true", done.Ref)
|
|
}
|
|
if !dn.IsTerminal {
|
|
t.Errorf("expected done task is_terminal=true")
|
|
}
|
|
if !hasGraphEdge(full, done.Ref, open.Ref, "blocks") {
|
|
t.Errorf("expected blocks edge restored with include_terminal=true, edges: %+v", full.Edges)
|
|
}
|
|
}
|
|
|
|
func TestGraphExcludesDeletedItems(t *testing.T) {
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
|
|
keep := createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Kept task", "fields": map[string]interface{}{"status": "open"},
|
|
})
|
|
gone := createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Deleted task", "fields": map[string]interface{}{"status": "open"},
|
|
})
|
|
createBlocksLink(t, srv, slug, keep.Slug, gone.ID)
|
|
|
|
rr := doRequest(srv, "DELETE", "/api/v1/workspaces/"+slug+"/items/"+gone.Slug, nil)
|
|
if rr.Code != http.StatusOK && rr.Code != http.StatusNoContent {
|
|
t.Fatalf("delete item: expected 200/204, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
resp := getGraph(t, srv, slug, "")
|
|
if graphNode(resp, gone.Ref) != nil {
|
|
t.Errorf("expected deleted item %s excluded", gone.Ref)
|
|
}
|
|
if len(resp.Edges) != 0 {
|
|
t.Errorf("expected edges to deleted items excluded, got %+v", resp.Edges)
|
|
}
|
|
}
|
|
|
|
// chainTasks creates n open tasks linked in a blocks chain
|
|
// t0 -> t1 -> ... -> t(n-1) and returns them in order.
|
|
func chainTasks(t *testing.T, srv *Server, slug string, n int) []models.Item {
|
|
t.Helper()
|
|
tasks := make([]models.Item, n)
|
|
for i := 0; i < n; i++ {
|
|
tasks[i] = createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Chain task", "fields": map[string]interface{}{"status": "open"},
|
|
})
|
|
}
|
|
for i := 0; i+1 < n; i++ {
|
|
createBlocksLink(t, srv, slug, tasks[i].Slug, tasks[i+1].ID)
|
|
}
|
|
return tasks
|
|
}
|
|
|
|
func TestGraphFocusDepth(t *testing.T) {
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
// t0 - t1 - t2 - t3 - t4 (undirected neighborhood for BFS).
|
|
tk := chainTasks(t, srv, slug, 5)
|
|
|
|
// depth=1 around t0 → t0, t1 only.
|
|
d1 := getGraph(t, srv, slug, "?focus="+tk[0].Ref+"&depth=1")
|
|
if len(d1.Nodes) != 2 {
|
|
t.Fatalf("depth=1: expected 2 nodes, got %d: %+v", len(d1.Nodes), d1.Nodes)
|
|
}
|
|
if graphNode(d1, tk[0].Ref) == nil || graphNode(d1, tk[1].Ref) == nil {
|
|
t.Errorf("depth=1: expected t0 and t1 present")
|
|
}
|
|
if graphNode(d1, tk[2].Ref) != nil {
|
|
t.Errorf("depth=1: expected t2 excluded")
|
|
}
|
|
|
|
// depth=2 around t0 → t0, t1, t2.
|
|
d2 := getGraph(t, srv, slug, "?focus="+tk[0].Ref+"&depth=2")
|
|
if len(d2.Nodes) != 3 {
|
|
t.Fatalf("depth=2: expected 3 nodes, got %d: %+v", len(d2.Nodes), d2.Nodes)
|
|
}
|
|
if graphNode(d2, tk[3].Ref) != nil {
|
|
t.Errorf("depth=2: expected t3 excluded")
|
|
}
|
|
|
|
// default depth (no param) is 2.
|
|
def := getGraph(t, srv, slug, "?focus="+tk[0].Ref)
|
|
if len(def.Nodes) != 3 {
|
|
t.Errorf("default depth: expected 3 nodes, got %d", len(def.Nodes))
|
|
}
|
|
|
|
// focus in the middle reaches both directions.
|
|
mid := getGraph(t, srv, slug, "?focus="+tk[2].Ref+"&depth=1")
|
|
if len(mid.Nodes) != 3 {
|
|
t.Fatalf("mid focus: expected 3 nodes (t1,t2,t3), got %d: %+v", len(mid.Nodes), mid.Nodes)
|
|
}
|
|
for _, ref := range []string{tk[1].Ref, tk[2].Ref, tk[3].Ref} {
|
|
if graphNode(mid, ref) == nil {
|
|
t.Errorf("mid focus: expected %s present", ref)
|
|
}
|
|
}
|
|
|
|
// depth is clamped — a huge depth still resolves the whole 5-chain,
|
|
// not an error.
|
|
clamped := getGraph(t, srv, slug, "?focus="+tk[0].Ref+"&depth=999")
|
|
if len(clamped.Nodes) != 5 {
|
|
t.Errorf("clamped depth: expected all 5 nodes, got %d", len(clamped.Nodes))
|
|
}
|
|
}
|
|
|
|
func TestGraphFocusTerminal(t *testing.T) {
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
|
|
// The focused item is always included, even when terminal.
|
|
done := createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Done focus", "fields": map[string]interface{}{"status": "done"},
|
|
})
|
|
active := createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Active neighbor", "fields": map[string]interface{}{"status": "open"},
|
|
})
|
|
createBlocksLink(t, srv, slug, done.Slug, active.ID)
|
|
|
|
resp := getGraph(t, srv, slug, "?focus="+done.Ref)
|
|
dn := graphNode(resp, done.Ref)
|
|
if dn == nil {
|
|
t.Fatalf("expected terminal focus node %s included", done.Ref)
|
|
}
|
|
if !dn.IsTerminal {
|
|
t.Errorf("expected focus node is_terminal=true")
|
|
}
|
|
if graphNode(resp, active.Ref) == nil {
|
|
t.Errorf("expected active neighbor %s included", active.Ref)
|
|
}
|
|
|
|
// A terminal neighbor is hidden by default, restored with the flag.
|
|
focus := createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Active focus", "fields": map[string]interface{}{"status": "open"},
|
|
})
|
|
doneNb := createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Done neighbor", "fields": map[string]interface{}{"status": "done"},
|
|
})
|
|
createBlocksLink(t, srv, slug, focus.Slug, doneNb.ID)
|
|
|
|
def := getGraph(t, srv, slug, "?focus="+focus.Ref)
|
|
if graphNode(def, doneNb.Ref) != nil {
|
|
t.Errorf("expected terminal neighbor %s hidden by default", doneNb.Ref)
|
|
}
|
|
full := getGraph(t, srv, slug, "?focus="+focus.Ref+"&include_terminal=true")
|
|
if graphNode(full, doneNb.Ref) == nil {
|
|
t.Errorf("expected terminal neighbor %s restored with include_terminal=true", doneNb.Ref)
|
|
}
|
|
}
|
|
|
|
func TestGraphFocusUnknownRef(t *testing.T) {
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Some task", "fields": map[string]interface{}{"status": "open"},
|
|
})
|
|
|
|
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/graph?focus=TASK-9999", nil)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404 for unknown focus ref, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGraphFocusCrossCollectionEdges(t *testing.T) {
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
|
|
plan := createItem(t, srv, slug, "plans", map[string]interface{}{
|
|
"title": "Focus plan", "fields": map[string]interface{}{"status": "active"},
|
|
})
|
|
task := createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Focus task", "fields": map[string]interface{}{"status": "open"},
|
|
})
|
|
bug := createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Related bug", "fields": map[string]interface{}{"status": "open"},
|
|
})
|
|
// task is a child of plan; task blocks bug.
|
|
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+task.Slug+"/links", map[string]interface{}{
|
|
"target_id": plan.ID, "link_type": "parent",
|
|
})
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("create parent link: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
createBlocksLink(t, srv, slug, task.Slug, bug.ID)
|
|
|
|
// Focus on the task at depth 1 pulls in both the parent plan and the
|
|
// blocked bug, across collections, with typed edges preserved.
|
|
resp := getGraph(t, srv, slug, "?focus="+task.Ref+"&depth=1")
|
|
if len(resp.Nodes) != 3 {
|
|
t.Fatalf("expected 3 nodes, got %d: %+v", len(resp.Nodes), resp.Nodes)
|
|
}
|
|
if !hasGraphEdge(resp, task.Ref, plan.Ref, "parent") {
|
|
t.Errorf("expected parent edge %s -> %s", task.Ref, plan.Ref)
|
|
}
|
|
if !hasGraphEdge(resp, task.Ref, bug.Ref, "blocks") {
|
|
t.Errorf("expected blocks edge %s -> %s", task.Ref, bug.Ref)
|
|
}
|
|
if resp.Truncated {
|
|
t.Errorf("did not expect truncation for a 3-node neighborhood")
|
|
}
|
|
}
|
|
|
|
func TestGraphFocusChildCountBeyondDepth(t *testing.T) {
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
|
|
// focus - mid(parent of leaf). At depth 1 from focus, mid is included
|
|
// but leaf (depth 2) is not — yet mid must still report child_count=1.
|
|
focus := createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Focus", "fields": map[string]interface{}{"status": "open"},
|
|
})
|
|
mid := createItem(t, srv, slug, "plans", map[string]interface{}{
|
|
"title": "Mid parent", "fields": map[string]interface{}{"status": "active"},
|
|
})
|
|
leaf := createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Leaf child", "fields": map[string]interface{}{"status": "open"},
|
|
})
|
|
createBlocksLink(t, srv, slug, focus.Slug, mid.ID)
|
|
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+leaf.Slug+"/links", map[string]interface{}{
|
|
"target_id": mid.ID, "link_type": "parent",
|
|
})
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("create parent link: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
resp := getGraph(t, srv, slug, "?focus="+focus.Ref+"&depth=1")
|
|
if graphNode(resp, leaf.Ref) != nil {
|
|
t.Fatalf("leaf should be beyond depth=1, but was included")
|
|
}
|
|
mn := graphNode(resp, mid.Ref)
|
|
if mn == nil {
|
|
t.Fatalf("mid node missing")
|
|
}
|
|
if mn.ChildCount != 1 {
|
|
t.Errorf("expected mid child_count=1 (true visible count) even though leaf omitted, got %d", mn.ChildCount)
|
|
}
|
|
}
|
|
|
|
func TestGraphFocusTruncation(t *testing.T) {
|
|
// Lower the cap so we don't have to create 200 items (which would
|
|
// trip the per-IP create rate limiter). Restore after.
|
|
orig := maxFocusNodes
|
|
maxFocusNodes = 5
|
|
defer func() { maxFocusNodes = orig }()
|
|
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
|
|
// A star larger than maxFocusNodes around a single focus node.
|
|
hub := createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Hub", "fields": map[string]interface{}{"status": "open"},
|
|
})
|
|
for i := 0; i < maxFocusNodes+5; i++ {
|
|
leaf := createItem(t, srv, slug, "tasks", map[string]interface{}{
|
|
"title": "Leaf", "fields": map[string]interface{}{"status": "open"},
|
|
})
|
|
createBlocksLink(t, srv, slug, hub.Slug, leaf.ID)
|
|
}
|
|
|
|
resp := getGraph(t, srv, slug, "?focus="+hub.Ref+"&depth=1")
|
|
if !resp.Truncated {
|
|
t.Errorf("expected truncated=true for an oversized neighborhood")
|
|
}
|
|
if len(resp.Nodes) != maxFocusNodes {
|
|
t.Errorf("expected node count capped at %d, got %d", maxFocusNodes, len(resp.Nodes))
|
|
}
|
|
}
|
|
|
|
// TestGraph_AdminBearer_RestrictedMemberIsScoped pins BUG-1917 for
|
|
// handleGetWorkspaceGraph: a platform admin who is only a restricted member
|
|
// (collection_access "specific") of a workspace gets the full unrestricted
|
|
// graph over a cookie session (the pre-existing web UI admin affordance),
|
|
// but must be scoped to their actual membership over a bearer token
|
|
// (PAT/CLI/OAuth) — the hidden collection's node must not appear, nor leak
|
|
// via a dangling edge endpoint.
|
|
func TestGraph_AdminBearer_RestrictedMemberIsScoped(t *testing.T) {
|
|
srv := testServer(t)
|
|
|
|
admin, err := srv.store.CreateUser(models.UserCreate{
|
|
Email: "admin@example.com", Name: "Admin", Password: "correct-horse-battery-staple", Role: "admin",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create admin: %v", err)
|
|
}
|
|
ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "GraphBearer", OwnerID: admin.ID})
|
|
if err != nil {
|
|
t.Fatalf("create workspace: %v", err)
|
|
}
|
|
if err := srv.store.AddWorkspaceMember(ws.ID, admin.ID, "editor"); err != nil {
|
|
t.Fatalf("add member: %v", err)
|
|
}
|
|
|
|
schema := `{"fields":[{"key":"status","type":"select","options":["open","done"],"default":"open"}]}`
|
|
visible, err := srv.store.CreateCollection(ws.ID, models.CollectionCreate{
|
|
Name: "Visible", Slug: "visible", Prefix: "VIS", Schema: schema,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create visible collection: %v", err)
|
|
}
|
|
hidden, err := srv.store.CreateCollection(ws.ID, models.CollectionCreate{
|
|
Name: "Hidden", Slug: "hidden", Prefix: "HID", Schema: schema,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create hidden collection: %v", err)
|
|
}
|
|
visItem, err := srv.store.CreateItem(ws.ID, visible.ID, models.ItemCreate{
|
|
Title: "Visible item", Fields: `{"status":"open"}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create visible item: %v", err)
|
|
}
|
|
hidItem, err := srv.store.CreateItem(ws.ID, hidden.ID, models.ItemCreate{
|
|
Title: "Hidden item", Fields: `{"status":"open"}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create hidden item: %v", err)
|
|
}
|
|
visItem.ComputeRef()
|
|
hidItem.ComputeRef()
|
|
if err := srv.store.SetMemberCollectionAccess(ws.ID, admin.ID, "specific", []string{visible.ID}); err != nil {
|
|
t.Fatalf("set member collection access: %v", err)
|
|
}
|
|
|
|
tok, err := srv.store.CreateAPIToken(admin.ID, models.APITokenCreate{
|
|
Name: "admin-pat", WorkspaceID: ws.ID,
|
|
}, 0, 0)
|
|
if err != nil {
|
|
t.Fatalf("CreateAPIToken: %v", err)
|
|
}
|
|
|
|
// Bearer path — scoped: hidden item's node must not appear.
|
|
rr := doRequestWithBearer(srv, "GET", "/api/v1/workspaces/"+ws.Slug+"/graph", tok.Token, nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("bearer graph: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var bearerResp GraphResponse
|
|
parseJSON(t, rr, &bearerResp)
|
|
if graphNode(bearerResp, hidItem.Ref) != nil {
|
|
t.Errorf("bearer admin graph leaked the hidden collection's node: %+v", bearerResp.Nodes)
|
|
}
|
|
if graphNode(bearerResp, visItem.Ref) == nil {
|
|
t.Errorf("bearer admin graph missing the visible collection's node: %+v", bearerResp.Nodes)
|
|
}
|
|
|
|
// Cookie path — unrestricted (the pre-existing web UI admin affordance).
|
|
sessTok, err := srv.store.CreateSession(admin.ID, "web-test", "192.0.2.1", "", webSessionTTL)
|
|
if err != nil {
|
|
t.Fatalf("CreateSession: %v", err)
|
|
}
|
|
rr = doRequestWithCookie(srv, "GET", "/api/v1/workspaces/"+ws.Slug+"/graph", nil, sessTok)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("cookie graph: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var cookieResp GraphResponse
|
|
parseJSON(t, rr, &cookieResp)
|
|
if graphNode(cookieResp, hidItem.Ref) == nil {
|
|
t.Errorf("cookie admin graph should be unrestricted (see hidden node); got %+v", cookieResp.Nodes)
|
|
}
|
|
}
|