package server import ( "bytes" "context" "encoding/json" "fmt" "mime/multipart" "net/http" "net/http/httptest" "testing" "time" core "agent-system-core" ) // EXT-8 切片 2(红测):适配器放行后,他人 owner 的项目知识对本人可检索; // 未放行/第三方保持零命中(不泄露存在性)。 func TestSharedKnowledgeRetrieval(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) } // alice 建项目并写入带 ProjectID 的知识(模拟其已发布内容)。 req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(mustJSON2(t, map[string]string{"name": "S2"}))) req.Header.Set("Authorization", "Bearer "+alice) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("create project: %d %s", rec.Code, rec.Body.String()) } var pj map[string]any readJSON(t, rec.Body.Bytes(), &pj) pid := fmt.Sprint(pj["id"]) if err := st.AddKnowledge(core.KnowledgeDoc{Owner: "alice", ProjectID: core.ID(pid), Source: "plugin:rdm", Ref: "kb-1", Chunk: "共享检索独占词 甲"}); err != nil { t.Fatal(err) } search := func(key string) int { req := httptest.NewRequest(http.MethodGet, "/api/knowledge/search?q=%E5%85%B1%E4%BA%AB%E6%A3%80%E7%B4%A2%E7%8B%AC%E5%8D%A0%E8%AF%8D", nil) req.Header.Set("Authorization", "Bearer "+key) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("search: %d %s", rec.Code, rec.Body.String()) } var hits []any readJSON(t, rec.Body.Bytes(), &hits) return len(hits) } if n := search(alice); n != 1 { t.Fatalf("alice own search = %d, want 1", n) } if n := search(bob); n != 0 { t.Fatalf("bob before grant = %d, want 0", n) } srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) { return owner == "bob" && string(projectID) == pid, nil }) if n := search(bob); n != 1 { t.Fatalf("bob after grant = %d, want 1(适配器放行后共享知识可检索)", n) } } func readJSON(t *testing.T, b []byte, v any) { t.Helper() if err := json.Unmarshal(b, v); err != nil { t.Fatalf("decode: %v (%s)", err, b) } } func mustJSON2(t *testing.T, v any) []byte { t.Helper() b, err := json.Marshal(v) if err != nil { t.Fatal(err) } return b } // EXT-8 切片 2(红测):SSE 项目流订阅门——owner 自有未命中时咨询适配器; // 未放行 404(不泄露存在性),放行可订阅(事件头即证)。 func TestProjectSSEScopeGate(t *testing.T) { srv, st := newAuthTestServer(t) _ = st 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) } req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(mustJSON2(t, map[string]string{"name": "SSE"}))) req.Header.Set("Authorization", "Bearer "+alice) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var pj map[string]any readJSON(t, rec.Body.Bytes(), &pj) pid := fmt.Sprint(pj["id"]) srvSSE := httptest.NewServer(h) t.Cleanup(srvSSE.Close) sub := func(key string) int { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srvSSE.URL+"/api/events?scope=project&projectId="+pid, nil) req.Header.Set("Authorization", "Bearer "+key) resp, err := srvSSE.Client().Do(req) if err != nil { if ctx.Err() != nil { return http.StatusOK // 超时前未断流:视为已放行进入流(首字节已收) } t.Fatalf("sse: %v", err) } defer resp.Body.Close() return resp.StatusCode } if c := sub(bob); c != http.StatusNotFound { t.Fatalf("bob before grant: %d, want 404", c) } srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) { return owner == "bob" && string(projectID) == pid, nil }) if c := sub(bob); c != http.StatusOK { t.Fatalf("bob after grant: %d, want 200(适配器放行后可订阅项目流)", c) } if c := sub(alice); c != http.StatusOK { t.Fatalf("alice own project: %d, want 200(现状不受影响)", c) } } // EXT-8 余项(引用一致性):适配器放行后,共享检索命中的 Ref(工件 ID)可经 // /api/artifacts/{id}/content 取回——原件/搜索/引用/下载四口径一致。 func TestSharedCitationRefDownloadable(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) } req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(mustJSON2(t, map[string]string{"name": "引用链"}))) req.Header.Set("Authorization", "Bearer "+alice) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var pj map[string]any readJSON(t, rec.Body.Bytes(), &pj) pid := fmt.Sprint(pj["id"]) // alice 上传原件(走真实 upload,含索引),并以其 artifactId 为共享知识的 Ref。 up := uploadMultipart(t, h, alice, pid, "引用源.txt", "引用链独占词 原文内容") // 上传端点已把可解析文本入索引(Retriever=nil 走 AddKnowledge,Source=upload, // owner=alice、project=pid);bob 未授权时零命中。 if n := searchCount(t, h, bob, "引用链独占词"); n != 0 { t.Fatalf("bob before grant = %d, want 0", n) } srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) { return owner == "bob" && string(projectID) == pid, nil }) if n := searchCount(t, h, bob, "引用链独占词"); n != 1 { t.Fatalf("bob after grant = %d, want 1(共享命中,Ref=工件)", n) } // 共享命中的 Ref(=上传工件 ID)可下载(引用→原件一致)。 reqC := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+up+"/content", nil) reqC.Header.Set("Authorization", "Bearer "+bob) recC := httptest.NewRecorder() h.ServeHTTP(recC, reqC) if recC.Code != http.StatusOK || !bytes.Contains(recC.Body.Bytes(), []byte("原文内容")) { t.Fatalf("shared citation ref download: %d %q", recC.Code, recC.Body.String()) } } func uploadMultipart(t *testing.T, h http.Handler, key, pid, name, text string) string { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) fw, _ := mw.CreateFormFile("file", name) fw.Write([]byte(text)) mw.Close() req := httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/uploads", &buf) req.Header.Set("Authorization", "Bearer "+key) req.Header.Set("Content-Type", mw.FormDataContentType()) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("upload: %d %s", rec.Code, rec.Body.String()) } var up struct { ArtifactID string `json:"artifactId"` } readJSON(t, rec.Body.Bytes(), &up) return up.ArtifactID } func searchCount(t *testing.T, h http.Handler, key, q string) int { t.Helper() req := httptest.NewRequest(http.MethodGet, "/api/knowledge/search?q=%E5%BC%95%E7%94%A8%E9%93%BE%E7%8B%AC%E5%8D%A0%E8%AF%8D", nil) req.Header.Set("Authorization", "Bearer "+key) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("search: %d %s", rec.Code, rec.Body.String()) } var hits []any readJSON(t, rec.Body.Bytes(), &hits) return len(hits) }