Why: System, role, and project entry permissions need explicit actions instead of a single administrator flag or unrestricted project creation. Evidence: The full Backend test suite passed, including role APIs, action isolation, project.create denial/allow, storage migration coverage, and account write fences. Impact: Adds versioned role and grant endpoints, maps account actions to explicit permissions, and requires project.create authority. Refs: Focused role and permission system task 3
227 lines
7.6 KiB
Go
227 lines
7.6 KiB
Go
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 := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
grantProjectCreateFixture(t, st, "alice")
|
||
bob, err := createLegacyAPIKeyFixture(t, st, "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 := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
grantProjectCreateFixture(t, st, "alice")
|
||
bob, err := createLegacyAPIKeyFixture(t, st, "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 := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
grantProjectCreateFixture(t, st, "alice")
|
||
bob, err := createLegacyAPIKeyFixture(t, st, "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)
|
||
}
|