agent-exec transport 5231be3441402d174e41c888e044ef05/78ce1b6b9dc4

This commit is contained in:
agent-exec-transport
2026-10-20 11:13:54 +00:00
parent 97c8cc8c23
commit 424c611a36
+58 -16
View File
@@ -8,6 +8,7 @@ import (
"strings"
"testing"
"agent-system-backend/internal/authz/catalog"
"agent-system-backend/internal/store"
)
@@ -57,7 +58,12 @@ func TestProjectCreateRequiresSystemOrRolePermission(t *testing.T) {
}
}
func TestProjectCreateLinksOwnProjectManagementOverHTTP(t *testing.T) {
// TestProjectCreateIsTheOnlyRolePolicyOverHTTP pins the narrowed role model
// (R2): a role carries the single project.create boolean, a batch naming any
// other action is refused outright, no linked rows are expanded on write, and
// the /api/me own-project link is a catalog projection of that boolean rather
// than something the role stored.
func TestProjectCreateIsTheOnlyRolePolicyOverHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
@@ -74,40 +80,76 @@ func TestProjectCreateLinksOwnProjectManagementOverHTTP(t *testing.T) {
t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String())
}
role := decodeRoleHTTP(t, roleRec)
conflict := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":1,"idempotencyKey":"create-conflict","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true},{"actionId":"project.members.manage","allowed":false,"capAllowed":false}]}`, admin)
if conflict.Code != http.StatusBadRequest {
t.Fatalf("linked conflict accepted: %d %s", conflict.Code, conflict.Body.String())
permissions := "/api/admin/roles/" + role.RoleID + "/permissions"
roleView := func() *httptest.ResponseRecorder {
return do(h, "GET", "/api/admin/roles/"+role.RoleID, "", admin)
}
stale := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":9,"idempotencyKey":"create-stale","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, admin)
const enableCreate = `{"actionId":"project.create","allowed":true,"capAllowed":true}`
const denyLinked = `{"actionId":"project.members.manage","allowed":false,"capAllowed":false}`
// A batch that enables project.create and explicitly denies a linked
// own-project action is refused as a whole: the linked action is not role
// authority any more, so nothing in that batch is written.
both := do(h, "PUT", permissions,
`{"expectedRoleVersion":1,"idempotencyKey":"create-and-deny-linked","changes":[`+enableCreate+`,`+denyLinked+`]}`, admin)
if both.Code != http.StatusBadRequest {
t.Fatalf("linked deny accepted beside create: %d %s", both.Code, both.Body.String())
}
stale := do(h, "PUT", permissions,
`{"expectedRoleVersion":9,"idempotencyKey":"create-stale","changes":[`+enableCreate+`]}`, admin)
if stale.Code != http.StatusConflict {
t.Fatalf("stale role version: %d %s", stale.Code, stale.Body.String())
}
policy := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":1,"idempotencyKey":"create-only","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, admin)
if policy.Code != http.StatusOK || !strings.Contains(policy.Body.String(), `"linkedExpansion":["project.settings","project.members.manage","project.permissions.manage","document.duties.assign"]`) {
policy := do(h, "PUT", permissions,
`{"expectedRoleVersion":1,"idempotencyKey":"create-only","changes":[`+enableCreate+`]}`, admin)
if policy.Code != http.StatusOK {
t.Fatalf("create-only policy: %d %s", policy.Code, policy.Body.String())
}
var receipt store.PolicyReceipt
if err := json.Unmarshal(policy.Body.Bytes(), &receipt); err != nil {
t.Fatalf("decode receipt: %v body=%s", err, policy.Body.String())
}
if receipt.ReceiptID == "" || receipt.RoleVersion != 2 || len(receipt.LinkedExpansion) != 0 ||
strings.Contains(policy.Body.String(), "linkedExpansion") {
t.Fatalf("receipt expanded linked rows: %+v body=%s", receipt, policy.Body.String())
}
requireRoleProjectAccess(t, roleView(), 2, true)
// Split across two submissions the linked deny is refused on its own, so the
// role keeps the project.create policy the first submission wrote.
denyLater := do(h, "PUT", permissions,
`{"expectedRoleVersion":2,"idempotencyKey":"deny-linked-later","changes":[`+denyLinked+`]}`, admin)
if denyLater.Code != http.StatusBadRequest {
t.Fatalf("linked deny accepted in a later batch: %d %s", denyLater.Code, denyLater.Body.String())
}
requireRoleProjectAccess(t, roleView(), 2, true)
assign := do(h, "PUT", "/api/admin/users/"+creator.ID+"/role",
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(creator.StateVersion, 10)+`,"idempotencyKey":"assign-creator"}`, admin)
if assign.Code != http.StatusOK {
t.Fatalf("assign creator: %d %s", assign.Code, assign.Body.String())
}
me := bearerRequest(h, "GET", "/api/me", "", key)
var body struct {
if me.Code != http.StatusOK {
t.Fatalf("me: %d %s", me.Code, me.Body.String())
}
var identity struct {
Linked struct {
ProjectCreate bool `json:"projectCreate"`
Source string `json:"source"`
LinkedActions []string `json:"linkedActions"`
Conflicts []string `json:"conflicts"`
} `json:"linkedCapabilities"`
}
if me.Code != http.StatusOK || json.Unmarshal(me.Body.Bytes(), &body) != nil {
t.Fatalf("me: %d %s", me.Code, me.Body.String())
if err := json.Unmarshal(me.Body.Bytes(), &identity); err != nil {
t.Fatalf("decode me: %v body=%s", err, me.Body.String())
}
if !body.Linked.ProjectCreate || body.Linked.Source != "role" ||
strings.Join(body.Linked.LinkedActions, ",") != "project.settings,project.members.manage,project.permissions.manage,document.duties.assign" {
t.Fatalf("linked capabilities=%+v", body.Linked)
if !identity.Linked.ProjectCreate || identity.Linked.Source != "role" || len(identity.Linked.Conflicts) != 0 ||
strings.Join(identity.Linked.LinkedActions, ",") != strings.Join(catalog.ProjectCreateLinkedActions(), ",") {
t.Fatalf("linked capabilities=%+v", identity.Linked)
}
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"创建者项目"}`, key); rec.Code != http.StatusOK {
t.Fatalf("role project create: %d %s", rec.Code, rec.Body.String())
}
}