Files
weichen 4d1ca60067 Expose role and system permission APIs
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
2026-10-14 17:02:11 +00:00

227 lines
7.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}