Why: Shared project reads previously doubled as write permission, project mutations bypassed the domain lifecycle, and coarse retrieval shared private resources. Evidence: SELF red-before-implementation logs cover projection, lifecycle, private work, resource filtering, canonical retrieval scope, publication source and namespace. SELF go test ./..., isolated PostgreSQL go test ./internal/store -count=1, and CGO_ENABLED=0 go build ./cmd/agent-system passed; task owner owns replay/CI. Impact: Add optional actor-scoped projection/lifecycle capabilities; registered providers control public lifecycle and live work. Preserve owned conversation history and ordinary unregistered behavior. Authorize knowledge by stored ID/source, map current host scopes through trusted service metadata, and filter artifact lists. Refs: Dev project-member-access implementation plan r1 Task 1; approved design r0.1; 2026-10-13 trusted publication Source hardening ruling
200 lines
9.1 KiB
Go
200 lines
9.1 KiB
Go
package server
|
|
|
|
import (
|
|
"agent-system-backend/internal/cmdbus"
|
|
"agent-system-backend/internal/surfacesvc"
|
|
"agent-system-backend/pkg/extension"
|
|
core "agent-system-core"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type lifecycleStatusError int
|
|
|
|
func (e lifecycleStatusError) Error() string { return "provider refusal" }
|
|
func (e lifecycleStatusError) StatusCode() int { return int(e) }
|
|
func lifecycleRequest(t *testing.T, s *Server, key, method, path, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
|
r.Header.Set("Authorization", "Bearer "+key)
|
|
w := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
func TestProjectLifecycleDelegatesAndPreservesArchiveHistory(t *testing.T) {
|
|
s, st := newAuthTestServer(t)
|
|
key, _ := st.CreateAPIKey("alice", "test")
|
|
p, _ := st.CreateProject("alice", "Original")
|
|
c, _ := st.CreateConversation("alice", p.ID, "Private", "")
|
|
calls := []string{}
|
|
s.SetProjectLifecycle(extension.ProjectLifecycle{
|
|
List: func(context.Context, extension.Host) ([]core.Project, error) {
|
|
calls = append(calls, "list")
|
|
return []core.Project{{ID: p.ID, Owner: "alice", Name: "Decorated", Settings: map[string]any{"rdm.state": "ready"}}}, nil
|
|
},
|
|
Create: func(_ context.Context, _ extension.Host, name string) (core.Project, error) {
|
|
calls = append(calls, "create")
|
|
return core.Project{ID: "provider-created", Name: name}, nil
|
|
},
|
|
Rename: func(_ context.Context, _ extension.Host, id, name string) (core.Project, error) {
|
|
calls = append(calls, "rename")
|
|
return core.Project{ID: id, Name: name}, nil
|
|
},
|
|
Archive: func(context.Context, extension.Host, string) error { calls = append(calls, "archive"); return nil },
|
|
Work: func(context.Context, string, core.ID) (bool, error) { return true, nil },
|
|
}, nil)
|
|
for _, q := range []struct{ method, path, body string }{{"GET", "/api/projects", ""}, {"POST", "/api/projects", `{"name":" New "}`}, {"PATCH", "/api/projects/" + p.ID, `{"name":"Renamed"}`}, {"DELETE", "/api/projects/" + p.ID, ""}} {
|
|
w := lifecycleRequest(t, s, key, q.method, q.path, q.body)
|
|
if w.Code != 200 {
|
|
t.Fatalf("%s: %d %s", q.method, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
if len(calls) != 4 || calls[0] != "list" || calls[1] != "create" || calls[2] != "rename" || calls[3] != "archive" {
|
|
t.Fatalf("calls=%v", calls)
|
|
}
|
|
if _, err := st.GetConversation("alice", c.ID); err != nil {
|
|
t.Fatalf("archive removed history: %v", err)
|
|
}
|
|
s.SetProjectLifecycle(extension.ProjectLifecycle{Create: func(context.Context, extension.Host, string) (core.Project, error) {
|
|
return core.Project{}, fmtWrap(lifecycleStatusError(409))
|
|
}}, nil)
|
|
if w := lifecycleRequest(t, s, key, "POST", "/api/projects", `{"name":"dup"}`); w.Code != 409 {
|
|
t.Fatalf("trusted status=%d %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
func fmtWrap(err error) error { return errors.Join(errors.New("wrapped"), err) }
|
|
func TestProjectLifecycleMemberPrivateWork(t *testing.T) {
|
|
s, st := newAuthTestServer(t)
|
|
alice, _ := st.CreateAPIKey("alice", "test")
|
|
bob, _ := st.CreateAPIKey("bob", "test")
|
|
mallory, _ := st.CreateAPIKey("mallory", "test")
|
|
p, _ := st.CreateProject("alice", "Shared")
|
|
ac, _ := st.CreateConversation("alice", p.ID, "Alice private", "")
|
|
ready := true
|
|
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
|
|
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
|
|
return ready && (actor == "alice" || actor == "bob") && id == core.ID(p.ID), nil
|
|
}}, nil)
|
|
w := lifecycleRequest(t, s, bob, "POST", "/api/projects/"+p.ID+"/conversations", `{"title":"Bob private"}`)
|
|
if w.Code != 200 {
|
|
t.Fatalf("member create=%d %s", w.Code, w.Body.String())
|
|
}
|
|
var bc core.Conversation
|
|
if err := json.Unmarshal(w.Body.Bytes(), &bc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w = lifecycleRequest(t, s, bob, "GET", "/api/projects/"+p.ID+"/conversations", "")
|
|
var cs []core.Conversation
|
|
_ = json.Unmarshal(w.Body.Bytes(), &cs)
|
|
if w.Code != 200 || len(cs) != 1 || cs[0].ID != bc.ID {
|
|
t.Fatalf("member list=%d %+v", w.Code, cs)
|
|
}
|
|
if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+ac.ID, ""); w.Code != 404 {
|
|
t.Fatalf("private history=%d", w.Code)
|
|
}
|
|
if w := lifecycleRequest(t, s, mallory, "POST", "/api/projects/"+p.ID+"/conversations", `{}`); w.Code != 404 {
|
|
t.Fatalf("nonmember create=%d", w.Code)
|
|
}
|
|
if w := lifecycleRequest(t, s, mallory, "GET", "/api/projects/"+p.ID+"/conversations", ""); w.Code != 404 {
|
|
t.Fatalf("nonmember list=%d", w.Code)
|
|
}
|
|
s.Bus = cmdbus.New(st, nil, func() time.Time { return time.Now() })
|
|
s.WireBuiltinCommands()
|
|
w = lifecycleRequest(t, s, bob, "POST", "/api/conversations/"+bc.ID+"/messages", `{"text":"/new"}`)
|
|
if w.Code != 200 || !bytes.Contains(w.Body.Bytes(), []byte(`"conversation"`)) {
|
|
t.Fatalf("/new=%d %s", w.Code, w.Body.String())
|
|
}
|
|
ready = false
|
|
for _, q := range []struct{ key, method, path, body string }{{bob, "POST", "/api/projects/" + p.ID + "/conversations", `{}`}, {bob, "GET", "/api/projects/" + p.ID + "/conversations", ""}, {alice, "POST", "/api/conversations/" + ac.ID + "/messages", `{"text":"new work"}`}, {bob, "POST", "/api/commands/conversation.create", `{"conversationId":"` + bc.ID + `"}`}} {
|
|
w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body)
|
|
if w.Code != 404 {
|
|
t.Fatalf("revoked %s=%d %s", q.path, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+bc.ID, ""); w.Code != 200 {
|
|
t.Fatalf("owned revoked history=%d", w.Code)
|
|
}
|
|
}
|
|
func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) {
|
|
s, st := newAuthTestServer(t)
|
|
key, _ := st.CreateAPIKey("bob", "test")
|
|
p, _ := st.CreateProject("alice", "Shared")
|
|
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
|
|
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
|
|
if w := lifecycleRequest(t, s, key, http.MethodPost, "/api/projects/"+p.ID+"/conversations", `{}`); w.Code != 404 {
|
|
t.Fatalf("read-only write=%d %s", w.Code, w.Body.String())
|
|
}
|
|
if cs, err := st.ListConversations("bob", p.ID); err != nil || len(cs) != 0 {
|
|
t.Fatalf("denial wrote state: %v %v", cs, err)
|
|
}
|
|
}
|
|
|
|
func TestProjectLifecycleRevocationGuardsExecutionResumption(t *testing.T) {
|
|
s, st := newAuthTestServer(t)
|
|
key, _ := st.CreateAPIKey("alice", "test")
|
|
p, _ := st.CreateProject("alice", "Project")
|
|
conv, _ := st.CreateConversation("alice", p.ID, "Private", "")
|
|
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil)
|
|
card := core.HITLCard{ID: "hitl-revoked", ConversationID: conv.ID, RunID: "run-revoked", Kind: "confirm"}
|
|
if err := st.SaveHITLCard("alice", &card); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, q := range []struct{ path, body string }{{"/api/conversations/" + conv.ID + "/regenerate", `{}`}, {"/api/hitl/" + card.ID + "/resolve", `{"choiceId":"yes"}`}} {
|
|
w := lifecycleRequest(t, s, key, "POST", q.path, q.body)
|
|
if w.Code != 404 {
|
|
t.Fatalf("resumption %s=%d %s", q.path, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProjectLifecycleRevocationGuardsSurfaceAction(t *testing.T) {
|
|
s, st := newAuthTestServer(t)
|
|
key, _ := st.CreateAPIKey("alice", "test")
|
|
p, _ := st.CreateProject("alice", "Project")
|
|
conv, _ := st.CreateConversation("alice", p.ID, "Private", "")
|
|
svc := surfacesvc.New(st, nil)
|
|
s.Surfaces = svc
|
|
if _, err := svc.SetActivation(context.Background(), "alice", "generative-ui", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snap, err := svc.Submit(context.Background(), "alice", "generative-ui", surfacesvc.SubmitRequest{ConversationID: conv.ID, Spec: json.RawMessage(`{"title":"Page","nodes":[{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"Submit"}]}]}`)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil)
|
|
w := lifecycleRequest(t, s, key, "POST", "/api/surfaces/"+snap.Identity.SurfaceID+"/actions", `{"actionId":"generative-ui.submit","revision":1,"requestId":"request"}`)
|
|
if w.Code != 404 {
|
|
t.Fatalf("revoked surface action=%d %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestProjectLifecycleSettingsRetainServiceMetadata(t *testing.T) {
|
|
s, st := newAuthTestServer(t)
|
|
key, _ := st.CreateAPIKey("alice", "test")
|
|
p, _ := st.CreateProject("alice", "Project")
|
|
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
|
|
current, err := st.GetProject("alice", p.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if current.Settings == nil {
|
|
current.Settings = map[string]any{}
|
|
}
|
|
current.Settings["rdm.projectId"] = "canonical"
|
|
current.Settings["rdm.state"] = "ready"
|
|
return []core.Project{current}, nil
|
|
}}, nil)
|
|
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, `{"settings":{"rdm.projectId":"forged","example":true}}`)
|
|
var got core.Project
|
|
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
|
if w.Code != 200 || got.Settings["rdm.projectId"] != "canonical" || got.Settings["example"] != true {
|
|
t.Fatalf("settings lost service metadata: %d %s", w.Code, w.Body.String())
|
|
}
|
|
}
|