Files
Agent-System-Backend/internal/server/resource_authz_test.go
T
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes.

Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending.

Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete.

Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
2026-10-14 07:33:54 +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 := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
if err != nil {
t.Fatal(err)
}
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])
}