Files
Agent-System-Backend/internal/server/resource_authz_test.go
T
weichen a4783d18e7
ci / vet-and-test (pull_request) Successful in 41s
Consult the EXT-8 authorizer in project artifact read paths
Why: With the surface and injection seam in place, the read endpoints must fall back to the adapter when owner-scoped lookup misses, keeping default-deny and fail-closed semantics.
Evidence: TestProjectResourceAuthorizerSharesReads green (default deny, shared list/meta/content via adapter, third-party denied, authorizer error fail-closed); go test ./... 32/32 packages ok.
Impact: listArtifacts/getArtifact/artifactContent consult authorizeSharedProject; store gains gated helpers (GetArtifactByID/ArtifactContentByID/ListProjectArtifactsAll) documented internal-only; writes untouched; SSE project stream and project card aggregate deliberately deferred to EXT-12 (conversation-card mirroring privacy).
Refs: EXT-8; red run at d89e748; task card.
2026-10-08 15:34:18 +00:00

124 lines
4.0 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 := st.CreateAPIKey("alice", "e2e")
if err != nil {
t.Fatal(err)
}
bob, err := st.CreateAPIKey("bob", "e2e")
if err != nil {
t.Fatal(err)
}
carol, err := st.CreateAPIKey("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])
}