110 lines
5.1 KiB
Go
110 lines
5.1 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"agent-system-backend/internal/store"
|
|
"agent-system-backend/pkg/extension"
|
|
)
|
|
|
|
func TestCapabilityExplainProviderExplainsBoundActorOnly(t *testing.T) {
|
|
st, err := store.OpenSQLite(t.TempDir() + "/capability-explain.db")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
alice, _ := st.GetAccountByOwner("alice")
|
|
role, err := st.CreateRole("admin", store.RoleCreate{Name: "项目负责人"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// 收窄模型(R2):职位角色只携带单一 project.create 行;document.* /
|
|
// project.* 等其他动作由系统授权或项目成员关系承担,写到角色行被
|
|
// ReplaceRolePermissions 拒绝(ErrInvalidRolePolicy)。fixture 只设
|
|
// project.create,断言围绕这条窄权限:CreateAuthority 投影到四个
|
|
// ProjectCreateLinkedActions、MemberBasicCollaborate 全 action-unconfigured、
|
|
// DocumentQualify 三职责都 action-unconfigured。
|
|
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion,
|
|
[]store.RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}},
|
|
"explain-provider"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "explain-provider-assign"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice", actor: "alice"}
|
|
provider, ok := any(h).(extension.CapabilityExplainProvider)
|
|
if !ok {
|
|
t.Fatal("dispatch host missing CapabilityExplainProvider")
|
|
}
|
|
ctx := context.Background()
|
|
capability, err := provider.CurrentCapabilityExplain(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if capability.SchemaVersion != extension.CapabilityExplainSchemaVersion || capability.UserID != alice.ID || capability.Owner != "alice" ||
|
|
capability.RoleID != role.RoleID || !capability.ProjectCreate.Allowed || capability.ProjectCreate.Source != extension.CapabilitySourceRole ||
|
|
len(capability.Linked) != 4 || len(capability.Conflicts) != 0 {
|
|
t.Fatalf("capability=%+v", capability)
|
|
}
|
|
snapshot, err := any(h).(extension.RolePolicyProvider).CurrentRolePolicy(ctx)
|
|
if err != nil || snapshot.Linked.Source != capability.ProjectCreate.Source || len(snapshot.Linked.Actions) != len(capability.Linked) {
|
|
t.Fatalf("snapshot linked=%+v err=%v", snapshot.Linked, err)
|
|
}
|
|
member, err := provider.CurrentMemberBasicCollaborate(ctx)
|
|
if err != nil || member.UserID != alice.ID || len(member.Actions) == 0 {
|
|
t.Fatalf("member=%+v err=%v", member, err)
|
|
}
|
|
// 角色权限投影:document.read 不在职位权限(只有 project.create),因此不能
|
|
// 被 Source=CapabilitySourceRole 标记为允许;来源必须是空、Reason 必须是
|
|
// "action-unconfigured"——这条边界保护正是窄模型的保护意图。
|
|
for _, action := range member.Actions {
|
|
if action.ActionID == "document.read" && (action.Allowed || action.Source == extension.CapabilitySourceRole || action.Reason != "action-unconfigured") {
|
|
t.Fatalf("document.read=%+v", action)
|
|
}
|
|
}
|
|
document, err := provider.CurrentDocumentQualify(ctx)
|
|
if err != nil || document.UserID != alice.ID || len(document.Duties) != 3 {
|
|
t.Fatalf("document=%+v err=%v", document, err)
|
|
}
|
|
// 三职责不被职位权限(project.create)直接资格化,因此 Qualified=false、
|
|
// DefaultAllowed=false、Source 空、Reason 为 action-unconfigured;这与
|
|
// 历史上「Allowed=false CapAllowed=true 单独写入」的职责资格语义不同——
|
|
// 那条路径已被 ReplaceRolePermissions 拒绝,本测试在不依赖该历史路径的
|
|
// 前提下保留职责资格投影的边界验证。
|
|
for _, duty := range document.Duties {
|
|
if duty.ActionID == "document.review" && (duty.Qualified || duty.DefaultAllowed || duty.Source == extension.CapabilitySourceRole || duty.Reason != "action-unconfigured") {
|
|
t.Fatalf("document.review=%+v", duty)
|
|
}
|
|
}
|
|
|
|
// A scoped Host without a dispatch actor cannot explain anyone.
|
|
scoped := any(inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice"}).(extension.CapabilityExplainProvider)
|
|
if _, err := scoped.CurrentCapabilityExplain(ctx); !errors.Is(err, store.ErrAccountForbidden) {
|
|
t.Fatalf("scoped capability: %v", err)
|
|
}
|
|
if _, err := scoped.CurrentMemberBasicCollaborate(ctx); !errors.Is(err, store.ErrAccountForbidden) {
|
|
t.Fatalf("scoped member: %v", err)
|
|
}
|
|
if _, err := scoped.CurrentDocumentQualify(ctx); !errors.Is(err, store.ErrAccountForbidden) {
|
|
t.Fatalf("scoped document: %v", err)
|
|
}
|
|
ghost := any(inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "ghost", actor: "ghost"}).(extension.CapabilityExplainProvider)
|
|
if _, err := ghost.CurrentCapabilityExplain(ctx); !errors.Is(err, store.ErrAccountForbidden) {
|
|
t.Fatalf("unknown actor: %v", err)
|
|
}
|
|
canceled, cancel := context.WithCancel(ctx)
|
|
cancel()
|
|
if _, err := provider.CurrentCapabilityExplain(canceled); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("canceled: %v", err)
|
|
}
|
|
}
|