ci / vet-and-test (pull_request) Successful in 40s
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.
143 lines
4.3 KiB
Go
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)
|
|
}
|
|
}
|