Files
Agent-System-Backend/internal/server/project_lifecycle_test.go
T

809 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/pkg/extension"
core "agent-system-core"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"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, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
grantProjectCreateFixture(t, st, "alice")
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, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
mallory, _ := createLegacyAPIKeyFixture(t, st, "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", ""}, {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())
}
}
// 已被移出项目的会话属主:发送消息答 403 not-project-member(不再谎称找不到会话)。
if msgs, err := st.ListMessages(ac.ID); err != nil || len(msgs) != 0 {
t.Fatalf("revoked history unreadable: %v %v", msgs, err)
}
if w := lifecycleRequest(t, s, alice, "POST", "/api/conversations/"+ac.ID+"/messages", `{"text":"new work"}`); w.Code != 403 ||
!bytes.Contains(w.Body.Bytes(), []byte("not-project-member")) {
t.Fatalf("revoked send=%d %s", 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)
}
}
// 仍是会话属主、但项目工作授权被拒(被移出项目)时,发送消息必须说清
// not-project-member 而不是 404,且不落库。
func TestProjectLifecycleRemovedMemberMessageIsForbidden(t *testing.T) {
s, st := newAuthTestServer(t)
alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
p, _ := st.CreateProject("alice", "Shared")
conv, _ := st.CreateConversation("alice", p.ID, "Alice private", "")
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
member := true
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
return member && actor == "alice" && id == core.ID(p.ID), nil
}}, nil)
if w := lifecycleRequest(t, s, alice, "POST", "/api/conversations/"+conv.ID+"/messages", `{"text":"仍在项目内"}`); w.Code != 200 {
t.Fatalf("member send=%d %s", w.Code, w.Body.String())
}
if msgs, err := st.ListMessages(conv.ID); err != nil || len(msgs) == 0 {
t.Fatalf("member message missing: %v %v", msgs, err)
}
member = false
w := lifecycleRequest(t, s, alice, "POST", "/api/conversations/"+conv.ID+"/messages", `{"text":"已被移出项目"}`)
if w.Code != 403 || !bytes.Contains(w.Body.Bytes(), []byte("not-project-member")) {
t.Fatalf("removed member send=%d %s", w.Code, w.Body.String())
}
msgs, err := st.ListMessages(conv.ID)
if err != nil {
t.Fatal(err)
}
for _, msg := range msgs {
if msg.Role != core.RoleUser {
continue
}
for _, part := range msg.Parts {
if part.Type == core.PartText && part.Text == "已被移出项目" {
t.Fatalf("denied send stored a message: %+v", msg)
}
}
}
if w := lifecycleRequest(t, s, alice, "GET", "/api/conversations/"+conv.ID, ""); w.Code != 200 {
t.Fatalf("owned conversation unreadable after removal=%d %s", w.Code, w.Body.String())
}
}
func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "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, _ := createLegacyAPIKeyFixture(t, st, "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, _ := createLegacyAPIKeyFixture(t, st, "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, _ := createLegacyAPIKeyFixture(t, st, "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())
}
}
func TestProjectLifecyclePublicMetadataMasksSecrets(t *testing.T) {
s, st := newAuthTestServer(t)
alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
grantProjectCreateFixture(t, st, "alice")
p, _ := st.CreateProject("alice", "Shared")
raw := map[string]any{"models.chat.apiKey": "fixture-key", "custom": map[string]any{"token": "fixture-token"}, "custom.api_key": "fixture-flat-token", "plugin.private.credential": "fixture-schema-token", "display": map[string]any{"caption": "Shared"}, "rdm.projectId": "canonical", "rdm.state": "ready"}
if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil {
t.Fatal(err)
}
s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugin.private.credential", Secret: true}})
read := func() (core.Project, error) { return st.GetProject("alice", p.ID) }
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
current, err := read()
return []core.Project{current}, err
}, Create: func(context.Context, extension.Host, string) (core.Project, error) { return read() }, Rename: func(context.Context, extension.Host, string, string) (core.Project, error) { return read() }}, nil)
for _, q := range []struct{ key, method, path, body string }{{bob, "GET", "/api/projects", ""}, {alice, "GET", "/api/projects", ""}, {alice, "POST", "/api/projects", `{"name":"New"}`}, {alice, "PATCH", "/api/projects/" + p.ID, `{"name":"Renamed"}`}, {alice, "PATCH", "/api/projects/" + p.ID, `{"settings":{"display":{"caption":"Updated"}}}`}} {
w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body)
if w.Code != 200 {
t.Fatalf("public metadata %s=%d", q.method, w.Code)
}
for _, secret := range []string{"fixture-key", "fixture-token", "fixture-flat-token", "fixture-schema-token"} {
if bytes.Contains(w.Body.Bytes(), []byte(secret)) {
t.Fatalf("%s %s leaked a fixture credential", q.method, q.path)
}
}
if !bytes.Contains(w.Body.Bytes(), []byte(`"rdm.projectId":"canonical"`)) || !bytes.Contains(w.Body.Bytes(), []byte(`"rdm.state":"ready"`)) {
t.Fatal("service metadata lost")
}
}
owned, err := read()
if err != nil {
t.Fatal(err)
}
if owned.Settings["models.chat.apiKey"] != "fixture-key" || owned.Settings["custom"].(map[string]any)["token"] != "fixture-token" {
t.Fatal("public metadata masking changed stored credentials")
}
}
func TestGenericProjectPatchRejectsManagedSettingsAtomically(t *testing.T) {
for _, registered := range []bool{false, true} {
t.Run(fmt.Sprint(registered), func(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
p, _ := st.CreateProject("alice", "Original")
s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.managed", Scope: core.SettingScopeSystem}})
if registered {
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
current, err := st.GetProject("alice", p.ID)
return []core.Project{current}, err
}, Rename: func(_ context.Context, _ extension.Host, id, name string) (core.Project, error) {
if err := st.RenameProject("alice", id, name); err != nil {
return core.Project{}, err
}
return st.GetProject("alice", id)
}}, nil)
}
for _, body := range []string{`{"name":"Changed","settings":{"models":{"chat":{"apiKey":"fixture-key"}},"display":{"caption":"Forbidden partial write"}}}`, `{"settings":{"models.chat.apiKey":"fixture-key"}}`, `{"settings":{"models":"smash"}}`, `{"settings":{"plugins.private.managed":"override"}}`} {
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, body)
if w.Code != 400 {
t.Fatalf("managed patch=%d", w.Code)
}
current, err := st.GetProject("alice", p.ID)
if err != nil {
t.Fatal(err)
}
if current.Name != "Original" || len(current.Settings) != 0 {
t.Fatal("rejected managed patch applied a partial mutation")
}
}
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, `{"settings":{"custom.caption":"Allowed"}}`)
if w.Code != 200 {
t.Fatalf("allowed patch=%d", w.Code)
}
current, err := st.GetProject("alice", p.ID)
if err != nil || current.Settings["custom.caption"] != "Allowed" {
t.Fatal("allowed metadata shape changed")
}
})
}
}
func TestGenericProjectPatchAcceptsNestedMaskedWriteback(t *testing.T) {
for _, registered := range []bool{false, true} {
t.Run(fmt.Sprint(registered), func(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
p, _ := st.CreateProject("alice", "Project")
if err := st.UpdateProjectSettings("alice", p.ID, map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "fixture-persisted-key"}}, "display": map[string]any{"caption": "Original"}}); err != nil {
t.Fatal(err)
}
if registered {
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
current, err := st.GetProject("alice", p.ID)
return []core.Project{current}, err
}}, nil)
}
for _, tc := range []struct{ body, caption string }{
{`{"settings":{"models":{"chat":{"apiKey":"••••••"}},"display":{"caption":"Nested updated"}}}`, "Nested updated"},
{`{"settings":{"models.chat":{"apiKey":"••••••"},"display":{"caption":"Mixed updated"}}}`, "Mixed updated"},
{`{"settings":{"models":{"chat.apiKey":"••••••"},"display":{"caption":"Mixed leaf updated"}}}`, "Mixed leaf updated"},
} {
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, tc.body)
if w.Code != 200 {
t.Fatalf("masked writeback rejected legal edit: %d", w.Code)
}
current, err := st.GetProject("alice", p.ID)
if err != nil {
t.Fatal(err)
}
if current.Settings["models"].(map[string]any)["chat"].(map[string]any)["apiKey"] != "fixture-persisted-key" || current.Settings["display"].(map[string]any)["caption"] != tc.caption {
t.Fatalf("masked writeback secret preserved=%v caption=%q expected=%q", current.Settings["models"].(map[string]any)["chat"].(map[string]any)["apiKey"] == "fixture-persisted-key", current.Settings["display"].(map[string]any)["caption"], tc.caption)
}
}
for _, body := range []string{`{"settings":{"models":{}}}`, `{"settings":{"models":{"chat":{}}}}`, `{"settings":{"models.chat":{}}}`, `{"settings":{"models":{"chat":{"apiKey":"••••••","model":"Overwrite"}}}}`} {
if w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, body); w.Code != 400 {
t.Fatalf("explicit empty/real managed write accepted: %d", w.Code)
}
}
})
}
}
func lifecycleUpload(t *testing.T, s *Server, key, projectID, name, content string) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
file, err := writer.CreateFormFile("file", name)
if err != nil {
t.Fatal(err)
}
if _, err := file.Write([]byte(content)); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/api/projects/"+projectID+"/uploads", &body)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
return rec
}
func TestProjectLifecycleMemberUploadStaysPrivate(t *testing.T) {
s, st := newAuthTestServer(t)
alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
mallory, _ := createLegacyAPIKeyFixture(t, st, "mallory", "test")
p, _ := st.CreateProject("alice", "Shared")
ready := true
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
return ready && id == p.ID && (actor == "alice" || actor == "bob"), nil
}}, nil)
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
if q.ResourceType == "artifact" {
return q.Actor == q.Owner, nil
}
return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil
})
s.DisableUploadIndexing = true
w := lifecycleUpload(t, s, bob, p.ID, "member-private.txt", "QA_MEMBER_PRIVATE_ORIGINAL")
if w.Code != 200 {
t.Fatalf("member upload=%d", w.Code)
}
var info core.UploadInfo
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil {
t.Fatal(err)
}
artifact, err := st.GetArtifact("bob", info.ArtifactID)
if err != nil || artifact.ProjectID != p.ID || artifact.Owner != "bob" {
t.Fatalf("upload ownership/target incorrect: %+v %v", artifact, err)
}
if w := lifecycleRequest(t, s, bob, "GET", "/api/artifacts/"+info.ArtifactID+"/content", ""); w.Code != 200 || w.Body.String() != "QA_MEMBER_PRIVATE_ORIGINAL" {
t.Fatalf("owner original read=%d", w.Code)
}
for _, key := range []string{alice, mallory} {
for _, suffix := range []string{"", "/content"} {
if w := lifecycleRequest(t, s, key, "GET", "/api/artifacts/"+info.ArtifactID+suffix, ""); w.Code != 404 {
t.Fatalf("other actor read private original=%d", w.Code)
}
}
}
if w := lifecycleUpload(t, s, mallory, p.ID, "forbidden.txt", "DENIED"); w.Code != 404 {
t.Fatalf("nonmember upload=%d", w.Code)
}
ready = false
if w := lifecycleUpload(t, s, bob, p.ID, "revoked.txt", "DENIED"); w.Code != 404 {
t.Fatalf("revoked upload=%d", w.Code)
}
artifacts, err := st.ListProjectArtifactsAll(p.ID)
if err != nil || len(artifacts) != 1 {
t.Fatalf("denied upload wrote artifacts: count=%d error=%v", len(artifacts), err)
}
}
func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) {
s, st := newAuthTestServer(t)
alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
p, _ := st.CreateProject("alice", "Shared")
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
return id == p.ID && (actor == "alice" || actor == "bob"), nil
}}, nil)
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil
})
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
s.Cards.SetAccountCheckedEmitter(s.mgr.EmitCardEventAccountChecked)
s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent)
s.DisableUploadIndexing = true
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p.ID, Title: "Shared structure", Kind: core.CardKindProgress}, ""); err != nil {
t.Fatal(err)
}
// The owner path already admitted upload before the member authorization fix.
if w := lifecycleUpload(t, s, alice, p.ID, "owner-private-filename.txt", "PRIVATE ORIGINAL"); w.Code != 200 {
t.Fatalf("owner upload=%d", w.Code)
}
if w := lifecycleUpload(t, s, bob, p.ID, "member-private-filename.txt", "MEMBER PRIVATE ORIGINAL"); w.Code != 200 {
t.Fatalf("member upload=%d", w.Code)
}
for _, owner := range []string{"alice", "bob"} {
cards, err := st.ListProjectCards(owner, p.ID)
if err != nil {
t.Fatal(err)
}
expected := 0
if owner == "alice" {
expected = 1
}
if len(cards) != expected {
t.Fatal("private upload added project card history")
}
for _, card := range cards {
if card.Title != "Shared structure" {
t.Fatal("private filename/parser card entered project history")
}
}
}
events, err := st.EventsAfter(core.ProjectChannelKey(p.ID), 0, 100)
if err != nil {
t.Fatal(err)
}
if len(events) != 1 {
t.Fatal("private upload added project events")
}
for _, event := range events {
if bytes.Contains(event.Payload, []byte("owner-private-filename.txt")) || bytes.Contains(event.Payload, []byte("member-private-filename.txt")) {
t.Fatal("private upload metadata entered persisted project stream")
}
}
request := httptest.NewRequest("GET", "/api/events?scope=project&projectId="+p.ID, nil)
request.Header.Set("Authorization", "Bearer "+bob)
ctx, cancel := context.WithTimeout(request.Context(), 20*time.Millisecond)
defer cancel()
request = request.WithContext(ctx)
response := httptest.NewRecorder()
s.Handler().ServeHTTP(response, request)
if response.Code != 200 || !bytes.Contains(response.Body.Bytes(), []byte("Shared structure")) {
t.Fatalf("member structural stream did not replay: %d", response.Code)
}
if bytes.Contains(response.Body.Bytes(), []byte("owner-private-filename.txt")) || bytes.Contains(response.Body.Bytes(), []byte("member-private-filename.txt")) {
t.Fatal("another member's SSE exposed private upload metadata")
}
}
func TestUnregisteredUploadKeepsLegacyCardsAndValidation(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
p, _ := st.CreateProject("alice", "Private")
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
s.Cards.SetAccountCheckedEmitter(s.mgr.EmitCardEventAccountChecked)
s.DisableUploadIndexing = true
if w := lifecycleUpload(t, s, key, p.ID, "legacy-upload.txt", "Original"); w.Code != 200 {
t.Fatalf("unregistered owner upload=%d", w.Code)
}
cards, err := st.ListProjectCards("alice", p.ID)
if err != nil || len(cards) != 1 || cards[0].Title != "legacy-upload.txt" {
t.Fatalf("unregistered ingestion card changed: count=%d error=%v", len(cards), err)
}
if w := lifecycleRequest(t, s, key, "POST", "/api/projects/missing/uploads", "not multipart"); w.Code != 400 {
t.Fatalf("legacy missing-file validation changed: %d", w.Code)
}
}
func TestProjectLifecycleUploadRechecksBeforePersistence(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
p, _ := st.CreateProject("alice", "Shared")
ready := true
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) {
allowed := ready
ready = false
return allowed, nil
}}, nil)
if w := lifecycleUpload(t, s, key, p.ID, "revoked-during-upload.txt", "PRIVATE"); w.Code != 404 {
t.Fatalf("upload persisted after authorization changed: %d", w.Code)
}
artifacts, err := st.ListProjectArtifactsAll(p.ID)
if err != nil || len(artifacts) != 0 {
t.Fatal("revoked upload created an artifact")
}
}
// ---- U3:被移除成员(removed)的会话面——读放行、写关闭(与 RDM U3a 配对) ----
//
// RDM 侧 U3a(成员状态机 active/removed/absent)经两个受信扩展面回答 Backend:
// ProjectLifecycle.Work(活着的工作权限)与 extension.ResourceAuthzQuery
// (共享域读)。被移除成员失去全部工作与写权限,只保留共享域读的只读路径。
// 被测面是真实 HTTP 服务,供应侧换成可注入的成员状态 fixture。
const (
memberActive = "active"
memberRemoved = "removed"
memberAbsent = "absent"
removedMemberHistoryProbe = "REMOVED_MEMBER_HISTORY_PROBE"
removedMemberWriteProbe = "REMOVED_MEMBER_WRITE_PROBE"
)
// memberStateFixture 是可注入的 RDM 供应侧:同一成员状态机回答上面两个面。
type memberStateFixture struct {
owner string
member string
projectID string
memberState string
readAllowed bool
queries []extension.ResourceAuthzQuery
}
func (f *memberStateFixture) stateOf(actor string) string {
switch {
case actor == f.owner:
return memberActive
case actor == f.member:
if f.memberState == "" {
return memberActive
}
return f.memberState
}
return memberAbsent
}
// work 是活工作探针:只有 active 成员可工作,removed 与 absent 一律 false。
func (f *memberStateFixture) work(_ context.Context, actor string, id core.ID) (bool, error) {
return id == core.ID(f.projectID) && f.stateOf(actor) == memberActive, nil
}
// authorize 是唯一资源授权查询面:读动作跟随成员状态(removed 只保留共享域读),
// 写与副作用动作对任何成员状态都拒绝(与 RDM 侧 IsMember 只认 active 一致)。
func (f *memberStateFixture) authorize(q extension.ResourceAuthzQuery) (bool, error) {
f.queries = append(f.queries, q)
if q.ResourceType != "project" || q.ResourceID != core.ID(f.projectID) || q.ProjectID != core.ID(f.projectID) {
return false, nil
}
switch q.Action {
case "read", "list", "stream":
default:
return false, nil
}
if state := f.stateOf(q.Actor); state != memberActive {
return state == memberRemoved && f.readAllowed, nil
}
return true, nil
}
// removedMemberWorld 是上面 fixture 装好的真实服务:alice 是项目属主,bob 是
// 被移除的成员,mallory 从未入项目。bob 的会话属主就是 bob 自己。
type removedMemberWorld struct {
s *Server
st *store.Store
fixture *memberStateFixture
keys map[string]string
project core.Project
mine core.Conversation
other core.Conversation
}
func newRemovedMemberWorld(t *testing.T, state string) *removedMemberWorld {
t.Helper()
s, st := newAuthTestServer(t)
keys := map[string]string{}
for _, owner := range []string{"alice", "bob", "mallory"} {
key, err := createLegacyAPIKeyFixture(t, st, owner, "test")
if err != nil {
t.Fatal(err)
}
keys[owner] = key
}
project, err := st.CreateProject("alice", "Shared")
if err != nil {
t.Fatal(err)
}
mine, err := st.CreateConversationInProject("bob", project.ID, "Bob session", "")
if err != nil {
t.Fatal(err)
}
other, err := st.CreateConversation("alice", project.ID, "Alice private", "")
if err != nil {
t.Fatal(err)
}
if err := st.AppendMessage(&core.Message{ConversationID: mine.ID, Role: core.RoleUser,
Parts: []core.Part{{Type: core.PartText, Text: removedMemberHistoryProbe}}}); err != nil {
t.Fatal(err)
}
fixture := &memberStateFixture{owner: "alice", member: "bob", projectID: project.ID, memberState: state, readAllowed: true}
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: fixture.work}, nil)
s.SetResourceAuthorizerV2(fixture.authorize)
s.DisableUploadIndexing = true
return &removedMemberWorld{s: s, st: st, fixture: fixture, keys: keys, project: project, mine: mine, other: other}
}
func (w *removedMemberWorld) listPath() string {
return "/api/projects/" + string(w.project.ID) + "/conversations"
}
// requireDenied 固化既有 fail-closed 语义:被移除成员与非成员在写门上一律
// 403/404(不泄露存在性),两者都算拒绝——放行成 200 才是回归放宽。
func requireDenied(t *testing.T, rec *httptest.ResponseRecorder, label string) {
t.Helper()
if rec.Code != http.StatusForbidden && rec.Code != http.StatusNotFound {
t.Fatalf("%s: gate opened (%d) %s", label, rec.Code, rec.Body.String())
}
}
// TestRemovedMemberReadsOwnSessionHistory 覆盖 (a):removed+共享读放行时,
// 被移除成员读本人会话列表、会话详情与消息历史成功,且不新增任何可见性。
func TestRemovedMemberReadsOwnSessionHistory(t *testing.T) {
w := newRemovedMemberWorld(t, memberRemoved)
bob := w.keys["bob"]
rec := lifecycleRequest(t, w.s, bob, http.MethodGet, w.listPath(), "")
var cs []core.Conversation
_ = json.Unmarshal(rec.Body.Bytes(), &cs)
if rec.Code != 200 || len(cs) != 1 || cs[0].ID != w.mine.ID {
t.Fatalf("removed member own list=%d %s", rec.Code, rec.Body.String())
}
// 放行必须来自同一资源授权查询面(不是第二套 membership 判定)。
granted := false
for _, q := range w.fixture.queries {
if q.Actor == "bob" && q.Action == "read" && q.ResourceType == "project" &&
q.ResourceID == core.ID(w.project.ID) && q.ProjectID == core.ID(w.project.ID) && q.AuthorityEpoch >= 1 {
granted = true
}
}
if !granted {
t.Fatalf("read decision did not consume the resource authorization query: %+v", w.fixture.queries)
}
// 会话详情与消息历史(只读)。
rec = lifecycleRequest(t, w.s, bob, http.MethodGet, "/api/conversations/"+string(w.mine.ID), "")
var detail struct {
Conversation core.Conversation `json:"conversation"`
Messages []core.Message `json:"messages"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &detail)
if rec.Code != 200 || string(detail.Conversation.ID) != string(w.mine.ID) || len(detail.Messages) != 1 ||
!bytes.Contains(rec.Body.Bytes(), []byte(removedMemberHistoryProbe)) {
t.Fatalf("removed member own detail=%d %s", rec.Code, rec.Body.String())
}
rec = lifecycleRequest(t, w.s, bob, http.MethodGet, "/api/conversations/"+string(w.mine.ID)+"/export", "")
if rec.Code != 200 || !bytes.Contains(rec.Body.Bytes(), []byte(removedMemberHistoryProbe)) {
t.Fatalf("removed member own export=%d %s", rec.Code, rec.Body.String())
}
// 不新增可见性:他人会话继续不可见。
for _, q := range []struct{ label, key, path string }{
{"other member session", bob, "/api/conversations/" + string(w.other.ID)},
{"nonmember list", w.keys["mallory"], w.listPath()},
{"nonmember removed member session", w.keys["mallory"], "/api/conversations/" + string(w.mine.ID)},
{"nonmember owner session", w.keys["mallory"], "/api/conversations/" + string(w.other.ID)},
{"owner reading another member session", w.keys["alice"], "/api/conversations/" + string(w.mine.ID)},
} {
if rec := lifecycleRequest(t, w.s, q.key, http.MethodGet, q.path, ""); rec.Code != http.StatusNotFound {
t.Fatalf("%s=%d %s", q.label, rec.Code, rec.Body.String())
}
}
}
// TestRemovedMemberWriteGatesStayClosed 覆盖 (b):写与副作用对 removed 一律拒绝。
func TestRemovedMemberWriteGatesStayClosed(t *testing.T) {
w := newRemovedMemberWorld(t, memberRemoved)
bob := w.keys["bob"]
w.s.Bus = cmdbus.New(w.st, nil, func() time.Time { return time.Now() })
w.s.WireBuiltinCommands()
card := core.HITLCard{ID: "hitl-removed-member", ConversationID: w.mine.ID, RunID: "run-removed-member", Kind: "confirm"}
if err := w.st.SaveHITLCard("bob", &card); err != nil {
t.Fatal(err)
}
for _, q := range []struct{ label, method, path, body string }{
{"create conversation", http.MethodPost, w.listPath(), `{"title":"Denied"}`},
{"send message", http.MethodPost, "/api/conversations/" + string(w.mine.ID) + "/messages", `{"text":"` + removedMemberWriteProbe + `"}`},
{"retry", http.MethodPost, "/api/conversations/" + string(w.mine.ID) + "/regenerate", `{}`},
{"resume command", http.MethodPost, "/api/commands/conversation.create", `{"conversationId":"` + string(w.mine.ID) + `"}`},
{"hitl resolve", http.MethodPost, "/api/hitl/hitl-removed-member/resolve", `{"choiceId":"yes"}`},
} {
requireDenied(t, lifecycleRequest(t, w.s, bob, q.method, q.path, q.body), "removed member "+q.label)
}
requireDenied(t, lifecycleUpload(t, w.s, bob, string(w.project.ID), "removed-member.txt", removedMemberWriteProbe), "removed member upload")
// 拒绝必须没有留下任何副作用。
if cs, err := w.st.ListConversations("bob", string(w.project.ID)); err != nil || len(cs) != 1 {
t.Fatalf("denied create wrote a conversation: count=%d error=%v", len(cs), err)
}
if msgs, err := w.st.ListMessages(string(w.mine.ID)); err != nil || len(msgs) != 1 {
t.Fatalf("denied send wrote messages: count=%d error=%v", len(msgs), err)
}
if artifacts, err := w.st.ListProjectArtifactsAll(string(w.project.ID)); err != nil || len(artifacts) != 0 {
t.Fatalf("denied upload wrote artifacts: count=%d error=%v", len(artifacts), err)
}
if stored, err := w.st.GetHITLCard("bob", "hitl-removed-member"); err != nil || stored.Resolved {
t.Fatalf("denied hitl resolve changed the card: %+v error=%v", stored, err)
}
}
// TestRemovedMemberWithoutSharedReadGrantStaysDenied 覆盖共享读被收回时读门同样关闭:
// 放行只能来自资源授权查询面的回答,不能因为「成员曾入项目」而无条件放行。
func TestRemovedMemberWithoutSharedReadGrantStaysDenied(t *testing.T) {
w := newRemovedMemberWorld(t, memberRemoved)
w.fixture.readAllowed = false
requireDenied(t, lifecycleRequest(t, w.s, w.keys["bob"], http.MethodGet, w.listPath(), ""), "removed member without shared read")
// 本人会话详情仍按会话所有权放行(既有语义,U3 未改动这一条)。
if rec := lifecycleRequest(t, w.s, w.keys["bob"], http.MethodGet, "/api/conversations/"+string(w.mine.ID), ""); rec.Code != 200 {
t.Fatalf("owned session detail=%d %s", rec.Code, rec.Body.String())
}
requireDenied(t, lifecycleRequest(t, w.s, w.keys["bob"], http.MethodPost, w.listPath(), `{}`), "removed member create without shared read")
}
// TestRestoredMemberRegainsSessionReadWrite 覆盖 (d):供应侧再报 active 后读写复原。
func TestRestoredMemberRegainsSessionReadWrite(t *testing.T) {
w := newRemovedMemberWorld(t, memberRemoved)
bob := w.keys["bob"]
requireDenied(t, lifecycleRequest(t, w.s, bob, http.MethodPost, w.listPath(), `{}`), "removed member create before restore")
w.fixture.memberState = memberActive
if rec := lifecycleRequest(t, w.s, bob, http.MethodGet, w.listPath(), ""); rec.Code != 200 {
t.Fatalf("restored member list=%d %s", rec.Code, rec.Body.String())
}
rec := lifecycleRequest(t, w.s, bob, http.MethodPost, w.listPath(), `{"title":"Restored"}`)
if rec.Code != 200 {
t.Fatalf("restored member create=%d %s", rec.Code, rec.Body.String())
}
var created core.Conversation
_ = json.Unmarshal(rec.Body.Bytes(), &created)
if string(created.ID) == "" || string(created.Owner) != "bob" || string(created.ProjectID) != string(w.project.ID) {
t.Fatalf("restored create shape=%+v", created)
}
if rec := lifecycleUpload(t, w.s, bob, string(w.project.ID), "restored.txt", "RESTORED"); rec.Code != 200 {
t.Fatalf("restored member upload=%d %s", rec.Code, rec.Body.String())
}
}