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") } }