Files
Agent-System-Backend/internal/server/shared_retrieval_test.go
T
weichen 1de663a8d3
ci / vet-and-test (pull_request) Successful in 40s
Implement shared knowledge retrieval, card mirror switch, and SSE project gate
Why: The three pinned reds define EXT-8 slice 2 - adapter-granted cross-owner knowledge must be retrievable, conversation cards must stay out of public project streams when the host opts out, and project-stream subscription must honor the authorizer.
Evidence: TestConvCardMirrorSwitch, TestSharedKnowledgeRetrieval, TestProjectSSEScopeGate green; go test ./... 32/32 packages ok; rows follow on the task card.
Impact: store gains predicate-based SearchKnowledgeShared (FTS/LIKE shared variants + PG); searchKnowledge appends adapter-authorized hits; cardsvc emit gate (no publish = no persistence = clean replay); events project scope consults authorizeSharedProject. Default behavior unchanged everywhere.
Refs: EXT-8/EXT-12 proposals; red run at surface commit.
2026-10-08 16:18:07 +00:00

143 lines
4.3 KiB
Go

package server
import (
"bytes"
"context"
"encoding/json"
"fmt"
"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)
}
}