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