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()) } }