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
125 lines
4.1 KiB
Go
125 lines
4.1 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
core "agent-system-core"
|
|
)
|
|
|
|
// EXT-8 资源授权适配器(红测:基础设施已就位、读取入口尚未咨询)——
|
|
// 默认拒绝保持现状;注册适配器后同项目成员可读共享资源、跨项目仍拒。
|
|
func TestProjectResourceAuthorizerSharesReads(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)
|
|
}
|
|
carol, err := createLegacyAPIKeyFixture(t, st, "carol", "e2e")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// alice 建项目并上传一份工件(owner=alice)。
|
|
prj := struct {
|
|
Name string `json:"name"`
|
|
}{Name: "EXT8 共享项目"}
|
|
req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(marshal(t, prj)))
|
|
req.Header.Set("Authorization", "Bearer "+alice)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("create project: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
pid := decodeField(t, rec.Body.Bytes(), "id")
|
|
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
fw, _ := mw.CreateFormFile("file", "共享说明.txt")
|
|
fw.Write([]byte("SHARED-CONTENT"))
|
|
mw.Close()
|
|
req = httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/uploads", &buf)
|
|
req.Header.Set("Authorization", "Bearer "+alice)
|
|
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())
|
|
}
|
|
aid := decodeField(t, rec.Body.Bytes(), "artifactId")
|
|
|
|
get := func(key, path string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.Header.Set("Authorization", "Bearer "+key)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// 默认拒绝(未注册适配器):bob 任何读取都是 NotFound,不泄露存在性。
|
|
for _, path := range []string{"/api/projects/" + pid, "/api/projects/" + pid + "/artifacts", "/api/artifacts/" + aid, "/api/artifacts/" + aid + "/content"} {
|
|
if rec := get(bob, path); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("default-deny: %s = %d, want 404", path, rec.Code)
|
|
}
|
|
}
|
|
|
|
// 注册适配器:bob 对该项目有共享授权;carol 无。
|
|
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
|
|
return owner == "bob" && string(projectID) == pid, nil
|
|
})
|
|
|
|
if rec := get(bob, "/api/artifacts/"+aid); rec.Code != http.StatusOK {
|
|
t.Fatalf("shared artifact meta: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
rec = get(bob, "/api/artifacts/"+aid+"/content")
|
|
if rec.Code != http.StatusOK || rec.Body.String() != "SHARED-CONTENT" {
|
|
t.Fatalf("shared artifact content: %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
rec = get(bob, "/api/projects/"+pid+"/artifacts")
|
|
if rec.Code != http.StatusOK || !bytes.Contains(rec.Body.Bytes(), []byte(aid)) {
|
|
t.Fatalf("shared artifact list: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// 跨项目/未授权用户与错误路径仍拒(NotFound;错误回调 fail-closed)。
|
|
for _, path := range []string{"/api/artifacts/" + aid, "/api/artifacts/" + aid + "/content"} {
|
|
if rec := get(carol, path); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("carol must stay denied: %s = %d", path, rec.Code)
|
|
}
|
|
}
|
|
srv.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return false, io.ErrClosedPipe })
|
|
if rec := get(bob, "/api/artifacts/"+aid); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("authorizer error must fail closed: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func marshal(t *testing.T, v any) []byte {
|
|
t.Helper()
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func decodeField(t *testing.T, body []byte, field string) string {
|
|
t.Helper()
|
|
var m map[string]any
|
|
if err := json.Unmarshal(body, &m); err != nil {
|
|
t.Fatalf("decode %s: %v (%s)", field, err, body)
|
|
}
|
|
return fmt.Sprint(m[field])
|
|
}
|