Files
Agent-System-Backend/internal/server/explain_test.go
T

391 lines
17 KiB
Go

package server
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// leakyStatusError carries provider detail that must never reach a client.
type leakyStatusError int
func (e leakyStatusError) Error() string { return "secret-project of other-owner already exists" }
func (e leakyStatusError) StatusCode() int { return int(e) }
const leakMarker = "other-owner"
type capabilityExplainHTTP struct {
UserID string `json:"userId"`
Owner string `json:"owner"`
RoleID string `json:"roleId"`
Capability struct {
ProjectCreate extension.CapabilityActionExplain `json:"projectCreate"`
Linked []extension.CapabilityActionExplain `json:"linked"`
Conflicts []string `json:"conflicts"`
MigrationNote string `json:"migrationNote"`
} `json:"capability"`
MemberBasicCollaborate struct {
Actions []extension.CapabilityActionExplain `json:"actions"`
} `json:"memberBasicCollaborate"`
DocumentQualify struct {
Duties []extension.DocumentDutyQualify `json:"duties"`
} `json:"documentQualify"`
}
func explainRoleFixture(t *testing.T, h http.Handler, st *store.Store, admin, owner, name, changes string) (store.Account, string) {
t.Helper()
if err := st.CreateAccount(owner, owner, "", false); err != nil {
t.Fatal(err)
}
account, _ := st.GetAccountByOwner(owner)
key, err := st.CreateAPIKey(owner, owner+"-explain")
if err != nil {
t.Fatal(err)
}
roleRec := do(h, "POST", "/api/admin/roles", `{"name":"`+name+`"}`, admin)
if roleRec.Code != http.StatusOK {
t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String())
}
role := decodeRoleHTTP(t, roleRec)
policy := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":1,"idempotencyKey":"policy-`+owner+`","changes":`+changes+`}`, admin)
if policy.Code != http.StatusOK {
t.Fatalf("policy: %d %s", policy.Code, policy.Body.String())
}
assign := do(h, "PUT", "/api/admin/users/"+account.ID+"/role",
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(account.StateVersion, 10)+`,"idempotencyKey":"assign-`+owner+`"}`, admin)
if assign.Code != http.StatusOK {
t.Fatalf("assign: %d %s", assign.Code, assign.Body.String())
}
return account, key
}
func decodeCapabilityExplain(t *testing.T, rec *httptest.ResponseRecorder) capabilityExplainHTTP {
t.Helper()
var out capabilityExplainHTTP
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &out) != nil {
t.Fatalf("explain: %d %s", rec.Code, rec.Body.String())
}
return out
}
func TestCapabilityExplainHTTPReturnsCreatorSourceAndLinkedActions(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
creator, key := explainRoleFixture(t, h, st, admin, "creator", "项目创建者",
`[{"actionId":"project.create","allowed":true,"capAllowed":true}]`)
rec := bearerRequest(h, "GET", "/api/capability/explain", "", key)
body := decodeCapabilityExplain(t, rec)
if body.Owner != "creator" || body.UserID != creator.ID || body.RoleID == "" {
t.Fatalf("subject=%+v", body)
}
create := body.Capability.ProjectCreate
if !create.Allowed || create.Source != extension.CapabilitySourceRole || create.Scope != extension.CapabilityScopeSystem {
t.Fatalf("project.create=%+v", create)
}
linked := []string{}
for _, action := range body.Capability.Linked {
if !action.Allowed || action.Source != "role" || action.LinkedVia != "project.create" || action.Scope != "own-projects" {
t.Fatalf("linked=%+v", action)
}
linked = append(linked, action.ActionID)
}
if strings.Join(linked, ",") != "project.settings,project.members.manage,project.permissions.manage,document.duties.assign" ||
len(body.Capability.Conflicts) != 0 || body.Capability.MigrationNote != "" {
t.Fatalf("capability=%+v", body.Capability)
}
// The /api/me linked view and the explanation agree on one snapshot.
me := bearerRequest(h, "GET", "/api/me", "", key)
if !strings.Contains(me.Body.String(), `"source":"role"`) {
t.Fatalf("me: %s", me.Body.String())
}
if strings.Contains(rec.Body.String(), `"admin"`) {
t.Fatalf("explanation named another account: %s", rec.Body.String())
}
}
// TestMemberBasicCollaborateAndDocumentQualifyOverHTTP pins the narrowed role
// model (R2) on the explanation surface: a role carries the single
// project-access boolean, so it neither grants project.create to a member nor
// configures any member action or document duty.
func TestMemberBasicCollaborateAndDocumentQualifyOverHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
_, key := explainRoleFixture(t, h, st, admin, "member", "协作成员", `[{"actionId":"project.create","allowed":false,"capAllowed":true}]`)
body := decodeCapabilityExplain(t, bearerRequest(h, "GET", "/api/capability/explain", "", key))
if body.Capability.ProjectCreate.Allowed || body.Capability.ProjectCreate.Reason != "role-cap" ||
len(body.Capability.Linked) != 4 || len(body.Capability.Conflicts) != 0 || body.Capability.MigrationNote != "" {
t.Fatalf("member capability=%+v", body.Capability)
}
for _, action := range body.Capability.Linked {
if action.Allowed || action.Source != "" || action.Reason != "project-create-not-held" || action.Scope != "own-projects" {
t.Fatalf("member linked=%+v", action)
}
}
member := map[string]extension.CapabilityActionExplain{}
for _, action := range body.MemberBasicCollaborate.Actions {
member[action.ActionID] = action
}
if len(member) != 4 {
t.Fatalf("member actions=%+v", body.MemberBasicCollaborate.Actions)
}
for actionID, action := range member {
want := extension.CapabilityActionExplain{ActionID: actionID, Scope: "member-projects", Reason: "action-unconfigured"}
if action != want {
t.Fatalf("%s=%+v want %+v", actionID, action, want)
}
}
duties := map[string]extension.DocumentDutyQualify{}
for _, duty := range body.DocumentQualify.Duties {
duties[duty.ActionID] = duty
}
if len(duties) != 3 {
t.Fatalf("duties=%+v", body.DocumentQualify.Duties)
}
for actionID, duty := range duties {
want := extension.DocumentDutyQualify{ActionID: actionID, Reason: "action-unconfigured"}
if duty != want {
t.Fatalf("%s=%+v want %+v", actionID, duty, want)
}
}
// Member content is refused at the policy endpoint instead of being stored.
role := decodeRoleHTTP(t, do(h, "POST", "/api/admin/roles", `{"name":"文档职责"}`, admin))
refused := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":1,"idempotencyKey":"duty-policy","changes":[{"actionId":"document.author","allowed":true,"capAllowed":true}]}`, admin)
if refused.Code != http.StatusBadRequest {
t.Fatalf("document duty accepted: %d %s", refused.Code, refused.Body.String())
}
// The refused batch left the role untouched: same version, no permissions.
stored := decodeRoleProjectAccess(t, do(h, "GET", "/api/admin/roles/"+role.RoleID, "", admin))
if stored.RoleVersion != 1 || stored.ProjectAccess || len(stored.Permissions) != 0 {
t.Fatalf("refused duty row changed the role: %+v", stored)
}
}
func TestUnauthorizedExplainReturnsNoLeakage(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
alice, _ := explainRoleFixture(t, h, st, admin, "alice", "项目创建者",
`[{"actionId":"project.create","allowed":true,"capAllowed":true}]`)
if _, err := st.CreateProject("alice", "alice-secret-project"); err != nil {
t.Fatal(err)
}
bobKey, err := createLegacyAPIKeyFixture(t, st, "bob", "bob-explain")
if err != nil {
t.Fatal(err)
}
noLeak := func(label string, rec *httptest.ResponseRecorder) {
t.Helper()
for _, secret := range []string{"alice", alice.ID, "alice-secret-project"} {
if strings.Contains(rec.Body.String(), secret) || strings.Contains(strings.Join(rec.Header().Values("X-Owner"), ","), secret) {
t.Fatalf("%s leaked %q: %d %s", label, secret, rec.Code, rec.Body.String())
}
}
}
// Naming another actor is refused identically whether it exists or not.
var refused string
for _, path := range []string{"/api/capability/explain?owner=alice", "/api/capability/explain?owner=nobody",
"/api/capability/explain?userId=" + alice.ID, "/api/capability/explain?userId=user_missing",
"/api/capability/explain?actor=alice", "/api/capability/explain?user=bob"} {
rec := bearerRequest(h, "GET", path, "", bobKey)
if rec.Code != http.StatusForbidden {
t.Fatalf("%s: %d %s", path, rec.Code, rec.Body.String())
}
noLeak(path, rec)
if refused == "" {
refused = rec.Body.String()
} else if rec.Body.String() != refused {
t.Fatalf("%s distinguishable refusal %q vs %q", path, rec.Body.String(), refused)
}
}
// A set-password-only session cannot read capability explanations.
if err := st.SetUserPassword("bob", mustHash(t, "bob-temp-pass")); err != nil {
t.Fatal(err)
}
restricted, err := st.CreateSessionScoped("bob", store.SessionScopeSetPasswordOnly, time.Hour)
if err != nil {
t.Fatal(err)
}
if rec := do(h, "GET", "/api/capability/explain", "", restricted); rec.Code != http.StatusForbidden {
t.Fatalf("restricted session: %d %s", rec.Code, rec.Body.String())
} else {
noLeak("restricted", rec)
}
// Missing, invalid and disabled credentials stop before any explanation.
if rec := do(h, "GET", "/api/capability/explain", "", ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("anonymous: %d", rec.Code)
} else {
noLeak("anonymous", rec)
}
if rec := bearerRequest(h, "GET", "/api/capability/explain", "", "ak_invalid"); rec.Code != http.StatusUnauthorized {
t.Fatalf("invalid key: %d", rec.Code)
} else {
noLeak("invalid", rec)
}
bob, _ := st.GetAccountByOwner("bob")
if _, err := st.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil {
t.Fatal(err)
}
if rec := bearerRequest(h, "GET", "/api/capability/explain", "", bobKey); rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden {
t.Fatalf("disabled: %d %s", rec.Code, rec.Body.String())
} else {
noLeak("disabled", rec)
}
// Defense in depth: the handler itself refuses an unavailable actor.
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/capability/explain", nil)
srv.explainCapability(rec, req.WithContext(context.WithValue(req.Context(), ownerCtxKey{}, "bob")))
if rec.Code != http.StatusForbidden || rec.Body.String() != "capability explain denied\n" {
t.Fatalf("handler disabled actor: %d %q", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
srv.explainCapability(rec, req.WithContext(context.WithValue(req.Context(), ownerCtxKey{}, "ghost")))
if rec.Code != http.StatusForbidden || rec.Body.String() != "capability explain denied\n" {
t.Fatalf("handler missing actor: %d %q", rec.Code, rec.Body.String())
}
}
func mustHash(t *testing.T, password string) string {
t.Helper()
hash, err := store.HashPassword(password)
if err != nil {
t.Fatal(err)
}
return hash
}
func TestCreateErrorCategorization(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
grantProjectCreateFixture(t, st, "alice")
mallory, _ := createLegacyAPIKeyFixture(t, st, "mallory", "test")
if w := lifecycleRequest(t, s, mallory, "POST", "/api/projects", `{"name":"x"}`); w.Code != http.StatusForbidden {
t.Fatalf("unauthorized create=%d %s", w.Code, w.Body.String())
}
cases := []struct {
name string
err error
code int
body string
}{
{"duplicate", extension.WorkFailure{Operation: "rdm", Reason: extension.WorkFailureDuplicateName}, 409, "project.create: duplicate-name\n"},
{"coded-duplicate", fmtWrap(leakyStatusError(409)), 409, "project.create: duplicate-name\n"},
{"policy", extension.WorkFailure{Reason: extension.WorkFailurePolicyRejected}, 403, "project.create: policy-rejected\n"},
{"identity", store.ErrAccountForbidden, 403, "project.create: policy-rejected\n"},
{"mapping", fmtWrap(extension.WorkFailure{Reason: extension.WorkFailureMappingNotReady}), 503, "project.create: mapping-not-ready\n"},
{"unavailable", leakyStatusError(503), 503, "project.create: service-unavailable\n"},
{"deadline", context.DeadlineExceeded, 503, "project.create: service-unavailable\n"},
}
for _, tc := range cases {
err := tc.err
s.SetProjectLifecycle(extension.ProjectLifecycle{Create: func(context.Context, extension.Host, string) (core.Project, error) {
return core.Project{}, err
}}, nil)
w := lifecycleRequest(t, s, key, "POST", "/api/projects", `{"name":"dup"}`)
if w.Code != tc.code || w.Body.String() != tc.body || strings.Contains(w.Body.String(), leakMarker) {
t.Fatalf("%s: %d %q", tc.name, w.Code, w.Body.String())
}
}
s.projectLifecycle = nil
if w := lifecycleRequest(t, s, key, "POST", "/api/projects", `{"name":"ok"}`); w.Code != http.StatusOK {
t.Fatalf("store create=%d %s", w.Code, w.Body.String())
}
}
func TestMemberAddErrorCategorization(t *testing.T) {
op := extension.WorkOperationMemberAdd
cases := []struct {
name string
err error
code int
body string
}{
{"duplicate", extension.WorkFailure{Reason: extension.WorkFailureDuplicateName}, 409, "project.member.add: duplicate-name\n"},
{"store-conflict", store.ErrConflict, 409, "project.member.add: duplicate-name\n"},
{"coded-conflict", leakyStatusError(409), 409, "project.member.add: duplicate-name\n"},
{"identity", store.ErrAccountForbidden, 403, "project.member.add: policy-rejected\n"},
{"policy", fmtWrap(leakyStatusError(403)), 403, "project.member.add: policy-rejected\n"},
{"mapping", extension.WorkFailure{Operation: op, Reason: extension.WorkFailureMappingNotReady}, 503, "project.member.add: mapping-not-ready\n"},
{"unavailable", extension.WorkFailure{Reason: extension.WorkFailureServiceUnavailable}, 503, "project.member.add: service-unavailable\n"},
{"conn-done", sql.ErrConnDone, 503, "project.member.add: service-unavailable\n"},
// Unknown targets stay a plain not-found, indistinguishable from absence.
{"not-found", store.ErrNotFound, 404, "store: not found\n"},
}
for _, tc := range cases {
w := httptest.NewRecorder()
writeWorkFailure(w, op, tc.err)
if w.Code != tc.code || w.Body.String() != tc.body || strings.Contains(w.Body.String(), leakMarker) {
t.Fatalf("%s: %d %q", tc.name, w.Code, w.Body.String())
}
}
if _, ok := categorizeWorkFailure(op, extension.WorkFailure{Reason: "invented"}); ok {
t.Fatal("unknown reason categorized")
}
if failure, ok := extension.CategorizeWorkFailure(op, errors.Join(errors.New("rdm"), extension.WorkFailure{Operation: "other", Reason: extension.WorkFailurePolicyRejected})); !ok ||
failure.Operation != op || failure.StatusCode() != http.StatusForbidden {
t.Fatalf("provider categorization=%+v ok=%v", failure, ok)
}
}
func TestConversationWorkErrorCategorization(t *testing.T) {
s, st := newAuthTestServer(t)
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
p, _ := st.CreateProject("alice", "Shared")
var workErr error
member := true
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) {
return member, workErr
}}, nil)
cases := []struct {
name string
err error
code int
body string
}{
{"mapping", extension.WorkFailure{Reason: extension.WorkFailureMappingNotReady}, 503, "conversation.work: mapping-not-ready\n"},
{"unavailable", fmtWrap(leakyStatusError(503)), 503, "conversation.work: service-unavailable\n"},
{"policy", extension.WorkFailure{Reason: extension.WorkFailurePolicyRejected}, 403, "conversation.work: policy-rejected\n"},
{"identity", store.ErrAccountForbidden, 403, "conversation.work: policy-rejected\n"},
}
for _, tc := range cases {
workErr = tc.err
for _, q := range []struct{ method, path, body string }{
{"POST", "/api/projects/" + p.ID + "/conversations", `{"title":"t"}`},
{"GET", "/api/projects/" + p.ID + "/conversations", ""},
} {
w := lifecycleRequest(t, s, bob, q.method, q.path, q.body)
if w.Code != tc.code || w.Body.String() != tc.body || strings.Contains(w.Body.String(), leakMarker) {
t.Fatalf("%s %s %s: %d %q", tc.name, q.method, q.path, w.Code, w.Body.String())
}
}
}
// A non-member and an unknown project are both plain not-found.
workErr, member = nil, false
for _, path := range []string{"/api/projects/" + p.ID + "/conversations", "/api/projects/prj_missing/conversations"} {
if w := lifecycleRequest(t, s, bob, "POST", path, `{}`); w.Code != http.StatusNotFound || strings.Contains(w.Body.String(), "alice") {
t.Fatalf("nonmember %s: %d %q", path, w.Code, w.Body.String())
}
}
// Conversation work has no names, so a conflict is never a duplicate name.
if _, ok := categorizeWorkFailure(extension.WorkOperationConversation, store.ErrConflict); ok {
t.Fatal("conversation conflict categorized as duplicate name")
}
if _, ok := categorizeWorkFailure(extension.WorkOperationConversation, leakyStatusError(409)); ok {
t.Fatal("conversation coded conflict categorized as duplicate name")
}
}