package server // EXT-15 红测(提案 2026-10-09-ext-g1-b1-b3 §3):成员(经适配器放行)能在 // GET /api/projects 里看到共享项目(shared 标注、去重、不含未授权),并能在 // 共享项目下建会话;撤销后两者立即拒绝;未注册适配器=现状。 import ( "bytes" "net/http" "net/http/httptest" "testing" core "agent-system-core" ) func TestSharedProjectListAndConversation(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() alice, err := st.CreateAPIKey("alice", "e2e") if err != nil { t.Fatal(err) } bob, err := st.CreateAPIKey("bob", "e2e") if err != nil { t.Fatal(err) } pj, err := st.CreateProject("alice", "共享项目X") if err != nil { t.Fatal(err) } pid := pj.ID type pv struct { ID string `json:"id"` Shared bool `json:"shared"` } call := func(key, method, path string) (*httptest.ResponseRecorder, []pv) { req := httptest.NewRequest(method, path, nil) req.Header.Set("Authorization", "Bearer "+key) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var list []pv if rec.Body.Len() > 0 { readJSON(t, rec.Body.Bytes(), &list) } return rec, list } // 未注册适配器:bob 看不到 alice 的项目(现状)。 rec, list := call(bob, http.MethodGet, "/api/projects") if rec.Code != http.StatusOK { t.Fatalf("list: %d", rec.Code) } if len(list) != 0 { t.Fatalf("before adapter: bob sees %d projects, want 0", len(list)) } // 放行 bob×pid。 srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) { return owner == "bob" && string(projectID) == pid, nil }) rec, list = call(bob, http.MethodGet, "/api/projects") if rec.Code != http.StatusOK { t.Fatalf("list after grant: %d", rec.Code) } if len(list) != 1 || list[0].ID != pid || !list[0].Shared { t.Fatalf("after grant: bob sees %+v, want 1 item id=%s shared=true", list, pid) } // alice 本人:仍只看到自己的(无 shared 标注)。 _, alist := call(alice, http.MethodGet, "/api/projects") if len(alist) != 1 || alist[0].ID != pid || alist[0].Shared { t.Fatalf("alice own list = %+v, want own without shared", alist) } // bob 在共享项目下建会话(EXT-15 第二入口)。 req := httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/conversations", bytes.NewReader(mustJSON2(t, map[string]string{"title": "成员会话"}))) req.Header.Set("Authorization", "Bearer "+bob) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("member create conversation: %d %s(须放行)", rec.Code, rec.Body.String()) } // 撤销:列表回到 0,建会话 404(不泄露存在性)。 srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) { return false, nil }) _, list = call(bob, http.MethodGet, "/api/projects") if len(list) != 0 { t.Fatalf("after revoke: bob sees %d, want 0", len(list)) } req = httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/conversations", bytes.NewReader(mustJSON2(t, map[string]string{"title": "x"}))) req.Header.Set("Authorization", "Bearer "+bob) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("after revoke: create conversation = %d, want 404", rec.Code) } }