Why: Accounts without project.create need an isolated general-agent chat; each scratch session is a private shell project (settings host.kind=scratch) with exactly one cross-private conversation. Evidence: agent-exec run 9b3d31a3 (pi, success); go test ./internal/store ./internal/server -run Scratch pass. Impact: New POST /api/scratch-sessions (no project.create needed); project lists omit scratch; second conversation under scratch 409; client writes cannot add or remove the scratch marker; POST /api/projects rejects the marker. Refs: Agent-System-Dev docs/superpowers/specs/2026-10-22-scratch-session-general-agent-design.md Co-authored-by: Cursor <cursoragent@cursor.com>
312 lines
14 KiB
Go
312 lines
14 KiB
Go
package server
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
|
||
"agent-system-backend/internal/authz/catalog"
|
||
"agent-system-backend/internal/store"
|
||
)
|
||
|
||
// 临时会话(U1)HTTP 契约:POST /api/scratch-sessions 对任何已启用账号内生可用,
|
||
// 不经 project.create;scratch 项目不进项目列表但会话仍出现在任务列表;1:1、
|
||
// 标记只归服务端、目录里没有可配置动作。
|
||
|
||
type scratchSessionHTTPView struct {
|
||
Project struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Settings map[string]any `json:"settings"`
|
||
} `json:"project"`
|
||
Conversation struct {
|
||
ID string `json:"id"`
|
||
ProjectID string `json:"projectId"`
|
||
Title string `json:"title"`
|
||
CrossPrivate bool `json:"crossPrivate"`
|
||
AllowCrossRead bool `json:"allowCrossRead"`
|
||
} `json:"conversation"`
|
||
}
|
||
|
||
func decodeScratchSessionHTTP(t *testing.T, rec *httptest.ResponseRecorder) scratchSessionHTTPView {
|
||
t.Helper()
|
||
var view scratchSessionHTTPView
|
||
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil {
|
||
t.Fatalf("decode scratch session: %v body=%s", err, rec.Body.String())
|
||
}
|
||
return view
|
||
}
|
||
|
||
func projectIDsOf(t *testing.T, rec *httptest.ResponseRecorder) []string {
|
||
t.Helper()
|
||
var rows []struct {
|
||
ID string `json:"id"`
|
||
}
|
||
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
|
||
t.Fatalf("decode projects: %v body=%s", err, rec.Body.String())
|
||
}
|
||
out := make([]string, 0, len(rows))
|
||
for _, row := range rows {
|
||
out = append(out, row.ID)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func conversationIDsOf(t *testing.T, rec *httptest.ResponseRecorder) []string {
|
||
t.Helper()
|
||
var rows []struct {
|
||
ID string `json:"id"`
|
||
}
|
||
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
|
||
t.Fatalf("decode conversations: %v body=%s", err, rec.Body.String())
|
||
}
|
||
out := make([]string, 0, len(rows))
|
||
for _, row := range rows {
|
||
out = append(out, row.ID)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func containsString(values []string, want string) bool {
|
||
for _, value := range values {
|
||
if value == want {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func createdProjectID(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||
t.Helper()
|
||
var project struct {
|
||
ID string `json:"id"`
|
||
}
|
||
if err := json.Unmarshal(rec.Body.Bytes(), &project); err != nil || project.ID == "" {
|
||
t.Fatalf("decode project: %v body=%s", err, rec.Body.String())
|
||
}
|
||
return project.ID
|
||
}
|
||
|
||
// scratchAccountKey 造一个没有密码的普通账号并签发 API key(与 role_authorization_test 同款)。
|
||
func scratchAccountKey(t *testing.T, st *store.Store, owner string) string {
|
||
t.Helper()
|
||
if err := st.CreateAccount(owner, owner, "", false); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
key, err := st.CreateAPIKey(owner, owner+"-scratch")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return key
|
||
}
|
||
|
||
// TestScratchSessionNeedsNoProjectCreatePermission 是 U1 的核心契约:没有
|
||
// project.create 的已启用账号照样能建临时会话,而同一个账号仍然建不了普通项目。
|
||
// 状态码沿用同类创建入口的 200(writeJSON),不是 201。
|
||
func TestScratchSessionNeedsNoProjectCreatePermission(t *testing.T) {
|
||
srv, st := newAuthTestServer(t)
|
||
h := srv.Handler()
|
||
key := scratchAccountKey(t, st, "engineer")
|
||
|
||
if allowed, _, reason := systemDecisionFixture(t, bearerRequest(h, "GET", "/api/authz/explain?action=project.create", "", key)); allowed || reason != "not-granted" {
|
||
t.Fatalf("fixture must not hold project.create: allowed=%v reason=%s", allowed, reason)
|
||
}
|
||
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key); rec.Code != http.StatusForbidden {
|
||
t.Fatalf("project created without project.create: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
|
||
rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("scratch session denied without project.create: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
view := decodeScratchSessionHTTP(t, rec)
|
||
if !store.IsScratchProject(view.Project.Settings) || view.Project.ID == "" {
|
||
t.Fatalf("scratch project marker missing: %s", rec.Body.String())
|
||
}
|
||
if view.Project.Name == "" || view.Conversation.ID == "" || view.Conversation.ProjectID != view.Project.ID {
|
||
t.Fatalf("incomplete scratch session: %s", rec.Body.String())
|
||
}
|
||
if !view.Conversation.CrossPrivate || !view.Conversation.AllowCrossRead {
|
||
t.Fatalf("scratch conversation flags: %s", rec.Body.String())
|
||
}
|
||
|
||
// 项目列表里没有 scratch,会话列表与项目会话列表里都有它。
|
||
projects := bearerRequest(h, "GET", "/api/projects", "", key)
|
||
if projects.Code != http.StatusOK || containsString(projectIDsOf(t, projects), view.Project.ID) {
|
||
t.Fatalf("scratch project listed as a normal project: %d %s", projects.Code, projects.Body.String())
|
||
}
|
||
tasks := bearerRequest(h, "GET", "/api/conversations", "", key)
|
||
if tasks.Code != http.StatusOK || !containsString(conversationIDsOf(t, tasks), view.Conversation.ID) {
|
||
t.Fatalf("scratch conversation missing from task list: %d %s", tasks.Code, tasks.Body.String())
|
||
}
|
||
under := bearerRequest(h, "GET", "/api/projects/"+view.Project.ID+"/conversations", "", key)
|
||
if under.Code != http.StatusOK || len(conversationIDsOf(t, under)) != 1 {
|
||
t.Fatalf("scratch project conversations: %d %s", under.Code, under.Body.String())
|
||
}
|
||
|
||
// 1:1:第二个会话被拒;普通项目下的会话创建不受影响。
|
||
second := bearerRequest(h, "POST", "/api/projects/"+view.Project.ID+"/conversations", `{"title":"第二个"}`, key)
|
||
if second.Code != http.StatusConflict || !strings.Contains(second.Body.String(), "scratch") {
|
||
t.Fatalf("second conversation under scratch project: %d %s", second.Code, second.Body.String())
|
||
}
|
||
after := bearerRequest(h, "GET", "/api/projects/"+view.Project.ID+"/conversations", "", key)
|
||
if len(conversationIDsOf(t, after)) != 1 {
|
||
t.Fatalf("refused create still wrote a conversation: %s", after.Body.String())
|
||
}
|
||
normal, err := st.CreateProjectAccountChecked("engineer", "普通项目", false)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if rec := bearerRequest(h, "POST", "/api/projects/"+string(normal.ID)+"/conversations", `{"title":"普通会话"}`, key); rec.Code != http.StatusOK {
|
||
t.Fatalf("normal project lost conversation create: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
}
|
||
|
||
// TestScratchSessionCreateForProjectCreateHolder 覆盖另一侧:已经持有
|
||
// project.create 的账号走同一条内生路径,不需要额外的开关。
|
||
func TestScratchSessionCreateForProjectCreateHolder(t *testing.T) {
|
||
srv, st := newAuthTestServer(t)
|
||
h := srv.Handler()
|
||
superAdmin := bootSuperAdmin(t, h, st)
|
||
key := scratchAccountKey(t, st, "creator")
|
||
createAssignedRoleFixture(t, h, st, superAdmin, "creator", "项目创建者", true)
|
||
if allowed, source, _ := systemDecisionFixture(t, bearerRequest(h, "GET", "/api/authz/explain?action=project.create", "", key)); !allowed || source != "role" {
|
||
t.Fatalf("fixture must hold project.create: allowed=%v source=%s", allowed, source)
|
||
}
|
||
|
||
rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{"name":"方案草稿","title":"临时会话"}`, key)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("scratch session denied for project creator: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
view := decodeScratchSessionHTTP(t, rec)
|
||
if !store.IsScratchProject(view.Project.Settings) || view.Project.Name != "方案草稿" || view.Conversation.Title != "临时会话" {
|
||
t.Fatalf("scratch session body: %s", rec.Body.String())
|
||
}
|
||
// 显式空体同样可用(默认名)。
|
||
if rec := bearerRequest(h, "POST", "/api/scratch-sessions", ``, key); rec.Code != http.StatusOK {
|
||
t.Fatalf("empty scratch body: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
}
|
||
|
||
// TestScratchSessionCreateRejectsDisabledAccount 停用账号既进不来(凭证被撤),
|
||
// 也不会绕过账号状态写出项目。
|
||
func TestScratchSessionCreateRejectsDisabledAccount(t *testing.T) {
|
||
srv, st := newAuthTestServer(t)
|
||
h := srv.Handler()
|
||
admin := bootAdmin(t, h, st)
|
||
key := scratchAccountKey(t, st, "disabled")
|
||
account, err := st.GetAccountByOwner("disabled")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
state := do(h, "PUT", "/api/admin/users/"+account.ID+"/state",
|
||
`{"expectedStateVersion":`+strconv.FormatInt(account.StateVersion, 10)+`,"enabled":false}`, admin)
|
||
if state.Code != http.StatusOK {
|
||
t.Fatalf("disable account: %d %s", state.Code, state.Body.String())
|
||
}
|
||
|
||
rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key)
|
||
// 停用会消费已有凭证,所以请求在认证中间件就被拒(401);即使凭证仍在,
|
||
// store 的账号有效性边界也会返回 403——两条路径都不写项目。
|
||
if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden {
|
||
t.Fatalf("disabled account scratch create: unexpected status %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
if rec.Code == http.StatusUnauthorized && !strings.Contains(rec.Body.String(), "revoked") {
|
||
t.Fatalf("disabled account scratch create: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
if _, _, err := st.CreateScratchSessionAccountChecked("disabled", "临时会话", "临时会话", "", false); err == nil {
|
||
t.Fatal("disabled account reached the scratch create past the account fence")
|
||
}
|
||
if projects, err := st.ListProjects("disabled"); err != nil || len(projects) != 0 {
|
||
t.Fatalf("disabled account left a project row: %+v %v", projects, err)
|
||
}
|
||
}
|
||
|
||
// TestScratchMarkerIsServerOwned 客户端既不能在普通项目上加标记,也不能从
|
||
// scratch 项目上摘掉,更不能借 POST /api/projects 直接造一个 scratch。
|
||
func TestScratchMarkerIsServerOwned(t *testing.T) {
|
||
srv, st := newAuthTestServer(t)
|
||
h := srv.Handler()
|
||
superAdmin := bootSuperAdmin(t, h, st)
|
||
key := scratchAccountKey(t, st, "creator")
|
||
createAssignedRoleFixture(t, h, st, superAdmin, "creator", "项目创建者", true)
|
||
|
||
normal := bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key)
|
||
if normal.Code != http.StatusOK {
|
||
t.Fatalf("create project: %d %s", normal.Code, normal.Body.String())
|
||
}
|
||
normalID := createdProjectID(t, normal)
|
||
scratch := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key)
|
||
if scratch.Code != http.StatusOK {
|
||
t.Fatalf("create scratch: %d %s", scratch.Code, scratch.Body.String())
|
||
}
|
||
scratchID := decodeScratchSessionHTTP(t, scratch).Project.ID
|
||
|
||
if rec := bearerRequest(h, "PATCH", "/api/projects/"+normalID, `{"settings":{"host":{"kind":"scratch"}}}`, key); rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("patch added the scratch marker: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
if rec := bearerRequest(h, "PATCH", "/api/projects/"+scratchID, `{"settings":{"host":{"kind":"normal"}}}`, key); rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("patch cleared the scratch marker: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"伪 scratch","settings":{"host":{"kind":"scratch"}}}`, key); rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("POST /api/projects created a scratch: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
if rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{"settings":{"host":{"kind":"scratch"}}}`, key); rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("scratch endpoint accepted client settings: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
// 不改标记的普通设置写入照旧放行。
|
||
if rec := bearerRequest(h, "PATCH", "/api/projects/"+normalID, `{"settings":{"note":"保留"}}`, key); rec.Code != http.StatusOK {
|
||
t.Fatalf("unrelated settings patch refused: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
// 被拒的写入没有落库:普通项目仍然不是 scratch。
|
||
stored, err := st.GetProject("creator", normalID)
|
||
if err != nil || store.IsScratchProject(stored.Settings) {
|
||
t.Fatalf("rejected patch changed the marker: %+v %v", stored.Settings, err)
|
||
}
|
||
}
|
||
|
||
// TestScratchIsNotAConfigurablePermission 权限目录与 replaceRolePermissions
|
||
// 都没有 scratch 创建动作:能力内生,不可授予也不可撤销。
|
||
func TestScratchIsNotAConfigurablePermission(t *testing.T) {
|
||
srv, st := newAuthTestServer(t)
|
||
h := srv.Handler()
|
||
admin := bootAdmin(t, h, st)
|
||
viewerKey := scratchAccountKey(t, st, "viewer")
|
||
|
||
rec := do(h, "GET", "/api/admin/permissions/catalog", "", admin)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("catalog: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
for _, descriptor := range catalog.Catalog() {
|
||
if strings.Contains(strings.ToLower(descriptor.ActionID), "scratch") {
|
||
t.Fatalf("catalog exposes a scratch action: %s", descriptor.ActionID)
|
||
}
|
||
}
|
||
if strings.Contains(rec.Body.String(), "scratch") {
|
||
t.Fatalf("catalog body mentions scratch: %s", rec.Body.String())
|
||
}
|
||
|
||
roleRec := do(h, "POST", "/api/admin/roles", `{"name":"临时会话职位"}`, admin)
|
||
if roleRec.Code != http.StatusOK {
|
||
t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String())
|
||
}
|
||
role := decodeRoleHTTP(t, roleRec)
|
||
for _, actionID := range []string{"project.scratch.create", "scratch.create", "scratch.session.create"} {
|
||
body := `{"expectedRoleVersion":1,"idempotencyKey":"scratch-action","changes":[{"actionId":"` + actionID + `","allowed":true,"capAllowed":true}]}`
|
||
if rec := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions", body, admin); rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("role policy accepted %s: %d %s", actionID, rec.Code, rec.Body.String())
|
||
}
|
||
}
|
||
view := do(h, "GET", "/api/admin/roles/"+role.RoleID, "", admin)
|
||
if strings.Contains(view.Body.String(), "scratch") {
|
||
t.Fatalf("role view mentions scratch: %s", view.Body.String())
|
||
}
|
||
me := bearerRequest(h, "GET", "/api/me", "", viewerKey)
|
||
if me.Code != http.StatusOK || strings.Contains(me.Body.String(), "scratch") {
|
||
t.Fatalf("me mentions a scratch capability: %d %s", me.Code, me.Body.String())
|
||
}
|
||
}
|