Why: Shared project reads previously doubled as write permission, project mutations bypassed the domain lifecycle, and coarse retrieval shared private resources. Evidence: SELF red-before-implementation logs cover projection, lifecycle, private work, resource filtering, canonical retrieval scope, publication source and namespace. SELF go test ./..., isolated PostgreSQL go test ./internal/store -count=1, and CGO_ENABLED=0 go build ./cmd/agent-system passed; task owner owns replay/CI. Impact: Add optional actor-scoped projection/lifecycle capabilities; registered providers control public lifecycle and live work. Preserve owned conversation history and ordinary unregistered behavior. Authorize knowledge by stored ID/source, map current host scopes through trusted service metadata, and filter artifact lists. Refs: Dev project-member-access implementation plan r1 Task 1; approved design r0.1; 2026-10-13 trusted publication Source hardening ruling
130 lines
4.5 KiB
Go
130 lines
4.5 KiB
Go
package server
|
|
|
|
// EXT-8b 读面消费红测(方案 r1 U9 第二切片):五处共享读路径在 V2 注册后按
|
|
// 资源级查询咨询(Actor/Action/ResourceType/ResourceID/Owner/ProjectID 由
|
|
// 受信上下文构造);V2 拒绝压过 V1 放行(资源级权威);V2 未注册时五处全部
|
|
// 回落 V1(兼容现行,RDM 现网行为不变)。红:当前 server 尚无 V2 槽位与
|
|
// AuthorizeResource 消费——注册接口不存在,编译红先行。
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"agent-system-backend/internal/store"
|
|
"agent-system-backend/pkg/extension"
|
|
core "agent-system-core"
|
|
)
|
|
|
|
func newV2CaptureServer(t *testing.T) (*Server, *store.Store, string, string, string) {
|
|
t.Helper()
|
|
srv, st := newAuthTestServer(t)
|
|
alice, err := st.CreateAPIKey("alice", "e2e")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bob, err := st.CreateAPIKey("bob", "e2e")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pj, err := st.CreateProject("alice", "资源级项目")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return srv, st, alice, bob, pj.ID
|
|
}
|
|
|
|
func TestSharedReadPathsConsultV2WithResourceContext(t *testing.T) {
|
|
srv, st, alice, bob, pid := newV2CaptureServer(t)
|
|
_ = alice
|
|
|
|
// 项目内造一个工件(供 download 路径取元数据)。
|
|
art := &core.Artifact{ProjectID: core.ID(pid), Owner: "alice", Name: "res.txt", Kind: "file", MIME: "text/plain"}
|
|
if err := st.CreateArtifact(art, []byte("body")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var got []extension.ResourceAuthzQuery
|
|
srv.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
|
|
got = append(got, q)
|
|
return true, nil
|
|
})
|
|
h := srv.Handler()
|
|
call := func(method, path string) int {
|
|
req := httptest.NewRequest(method, path, nil)
|
|
req.Header.Set("Authorization", "Bearer "+bob)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec.Code
|
|
}
|
|
|
|
// ① 工件下载(owner 自有未命中 → 资源级咨询)。
|
|
code := call(http.MethodGet, "/api/artifacts/"+string(art.ID)+"/content?download=1")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("download: %d", code)
|
|
}
|
|
// ② 项目工件清单。
|
|
call(http.MethodGet, "/api/projects/"+pid+"/artifacts")
|
|
// ③ 共享项目建会话。
|
|
call(http.MethodPost, "/api/projects/"+pid+"/conversations")
|
|
|
|
byAction := map[string]extension.ResourceAuthzQuery{}
|
|
for _, q := range got {
|
|
byAction[q.Action] = q
|
|
}
|
|
dl := byAction["download"]
|
|
if dl.Actor != "bob" || dl.ResourceType != "artifact" || dl.ResourceID != art.ID || dl.ProjectID != pid || dl.Owner != "alice" {
|
|
t.Fatalf("download 查询六要素不符: %+v", dl)
|
|
}
|
|
ls := byAction["list"]
|
|
if ls.Actor != "bob" || ls.ResourceType != "artifact" || ls.ResourceID != art.ID || ls.ProjectID != pid || ls.Owner != "alice" {
|
|
t.Fatalf("list 查询不符: %+v", ls)
|
|
}
|
|
rd := byAction["read"]
|
|
if rd.Actor != "bob" || rd.ResourceType != "project" || rd.ResourceID != pid {
|
|
t.Fatalf("read(conversation) 查询不符: %+v", rd)
|
|
}
|
|
}
|
|
|
|
func TestV2DenyOverridesV1Allow(t *testing.T) {
|
|
srv, st, alice, bob, pid := newV2CaptureServer(t)
|
|
_ = alice
|
|
art := &core.Artifact{ProjectID: core.ID(pid), Owner: "alice", Name: "d.txt", Kind: "file"}
|
|
if err := st.CreateArtifact(art, []byte("body")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// V1 全放行、V2 全拒绝——资源级权威必须压过 V1。
|
|
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
|
|
return true, nil
|
|
})
|
|
srv.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
|
|
return false, nil
|
|
})
|
|
req := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+string(art.ID)+"/content?download=1", nil)
|
|
req.Header.Set("Authorization", "Bearer "+bob)
|
|
rec := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("V2 拒绝须压过 V1 放行并保持 404(不泄露存在性), got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestV2AbsentFallsBackToV1(t *testing.T) {
|
|
srv, st, alice, bob, pid := newV2CaptureServer(t)
|
|
_ = alice
|
|
art := &core.Artifact{ProjectID: core.ID(pid), Owner: "alice", Name: "f.txt", Kind: "file"}
|
|
if err := st.CreateArtifact(art, []byte("body")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
|
|
return owner == "bob" && string(projectID) == pid, nil
|
|
})
|
|
req := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+string(art.ID)+"/content?download=1", nil)
|
|
req.Header.Set("Authorization", "Bearer "+bob)
|
|
rec := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("V2 未注册应回落 V1(现行行为), got %d", rec.Code)
|
|
}
|
|
}
|