POST /api/surfaces with an empty surfaceId now requires a conversationId that belongs to the caller: empty returns 400, missing or another owner's conversation returns 404. Revisions keep the previous behavior. Without this check a direct API caller could create a project-less surface and skip the project lifecycle gate on actions. Why: an empty conversationId produced surfaces with no project ownership. Evidence: agent-exec unit backend-surface-submit-conversation-required (pi, attempt 1 after authorized rerun, terminal=success); go test ./internal/server/... ./internal/surfacesvc/... . Impact: HTTP create-surface validation only. Refs: Dev task fix/svc-superadmin-envfile-surface-conv. Co-authored-by: Cursor <cursoragent@cursor.com>
223 lines
9.3 KiB
Go
223 lines
9.3 KiB
Go
package server
|
||
|
||
// Generative UI U2 红测(提案 KTD7):直接 API 与插件命令共用 surfacesvc
|
||
// 单一咽喉——禁用状态下 POST /api/surfaces 必须被拒(不能因入口绕过执行级
|
||
// 启禁),读取与启禁端点行为符合「历史保留 + epoch 纪元」语义。
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
|
||
"agent-system-backend/internal/store"
|
||
"agent-system-backend/internal/surfacesvc"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// surfaceConversationFixture 建调用者自有项目与会话:页面新建必须挂在真实
|
||
// 会话上(项目归属由会话表反查),占位 ID 走不通端点。
|
||
func surfaceConversationFixture(t *testing.T, st *store.Store, owner string) core.Conversation {
|
||
t.Helper()
|
||
project, err := st.CreateProject(owner, "surface fixture")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
conv, err := st.CreateConversation(owner, project.ID, "surface fixture", "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return conv
|
||
}
|
||
|
||
func TestSurfaceDirectAPIGuards(t *testing.T) {
|
||
srv, st := newAuthTestServer(t)
|
||
h := srv.Handler()
|
||
key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
svc := surfacesvc.New(st, nil)
|
||
srv.Surfaces = svc
|
||
convID := surfaceConversationFixture(t, st, "alice").ID
|
||
|
||
post := func(path, body string) *httptest.ResponseRecorder {
|
||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
|
||
req.Header.Set("Authorization", "Bearer "+key)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
rec := httptest.NewRecorder()
|
||
h.ServeHTTP(rec, req)
|
||
return rec
|
||
}
|
||
get := func(path string) *httptest.ResponseRecorder {
|
||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||
req.Header.Set("Authorization", "Bearer "+key)
|
||
rec := httptest.NewRecorder()
|
||
h.ServeHTTP(rec, req)
|
||
return rec
|
||
}
|
||
spec := `"spec":{"title":"t","nodes":[{"type":"markdown","id":"n1","markdown":"x"}]}`
|
||
|
||
// 未启用:提交被拒(403),提示能力存在但插件未启用
|
||
if rec := post("/api/surfaces", `{"pluginId":"genui","conversationId":"`+convID+`",`+spec+`}`); rec.Code != http.StatusForbidden {
|
||
t.Fatalf("disabled submit should 403, got %d: %s", rec.Code, rec.Body.String())
|
||
}
|
||
// 启用(activation 端点走服务层,epoch=1)
|
||
if rec := post("/api/plugins/genui/activation", `{"enabled":true}`); rec.Code != http.StatusOK {
|
||
t.Fatalf("enable failed: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
// 提交成功 → 201
|
||
rec := post("/api/surfaces", `{"pluginId":"genui","conversationId":"`+convID+`",`+spec+`}`)
|
||
if rec.Code != http.StatusCreated {
|
||
t.Fatalf("enabled submit should 201, got %d: %s", rec.Code, rec.Body.String())
|
||
}
|
||
var snap struct {
|
||
Identity struct {
|
||
SurfaceID string `json:"surfaceId"`
|
||
} `json:"identity"`
|
||
Revision int `json:"revision"`
|
||
}
|
||
readJSON(t, rec.Body.Bytes(), &snap)
|
||
|
||
// 读取可用;列表可用
|
||
if rec := get("/api/surfaces/" + snap.Identity.SurfaceID); rec.Code != http.StatusOK {
|
||
t.Fatalf("get failed: %d", rec.Code)
|
||
}
|
||
if rec := get("/api/conversations/" + convID + "/surfaces"); rec.Code != http.StatusOK {
|
||
t.Fatalf("list failed: %d", rec.Code)
|
||
}
|
||
// 禁用后:提交 403;读取仍 200(历史保留)
|
||
if rec := post("/api/plugins/genui/activation", `{"enabled":false}`); rec.Code != http.StatusOK {
|
||
t.Fatalf("disable failed: %d", rec.Code)
|
||
}
|
||
if rec := post("/api/surfaces", `{"pluginId":"genui","conversationId":"`+convID+`",`+spec+`}`); rec.Code != http.StatusForbidden {
|
||
t.Fatalf("post-disable submit should 403, got %d", rec.Code)
|
||
}
|
||
if rec := get("/api/surfaces/" + snap.Identity.SurfaceID); rec.Code != http.StatusOK {
|
||
t.Fatalf("post-disable read should stay 200, got %d", rec.Code)
|
||
}
|
||
// 未带凭据:401(认证面同现有守卫)
|
||
if rec := httptest.NewRecorder(); true {
|
||
req := httptest.NewRequest(http.MethodPost, "/api/surfaces", bytes.NewBufferString(`{"pluginId":"genui"}`))
|
||
h.ServeHTTP(rec, req)
|
||
if rec.Code != http.StatusUnauthorized {
|
||
t.Fatalf("unauthenticated should 401, got %d", rec.Code)
|
||
}
|
||
}
|
||
}
|
||
|
||
// U5:动作端点红测——禁用 403、未知动作 400、端点不绑定 confirmed(伪造无入口)。
|
||
func TestSurfaceActionEndpoint(t *testing.T) {
|
||
srv, st := newAuthTestServer(t)
|
||
h := srv.Handler()
|
||
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||
svc := surfacesvc.New(st, nil)
|
||
svc.SetDispatcher(func(ctx context.Context, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) {
|
||
return surfacesvc.ActionOutcome{State: "committed", Receipt: map[string]any{"ok": true}}, nil
|
||
})
|
||
srv.Surfaces = svc
|
||
convID := surfaceConversationFixture(t, st, "alice").ID
|
||
ctx := context.Background()
|
||
svc.SetActivation(ctx, "alice", "generative-ui", true)
|
||
spec := `"spec":{"title":"表单页","nodes":[{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"提交"}]}]}`
|
||
post := func(body string) *httptest.ResponseRecorder {
|
||
req := httptest.NewRequest(http.MethodPost, "/api/surfaces", bytes.NewBufferString(`{"pluginId":"generative-ui","conversationId":"`+convID+`",`+spec+`}`))
|
||
req.Header.Set("Authorization", "Bearer "+key)
|
||
rec := httptest.NewRecorder()
|
||
h.ServeHTTP(rec, req)
|
||
return rec
|
||
}
|
||
rec := post("")
|
||
if rec.Code != http.StatusCreated {
|
||
t.Fatalf("setup submit failed: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
var snap struct {
|
||
Identity struct {
|
||
SurfaceID string `json:"surfaceId"`
|
||
} `json:"identity"`
|
||
Revision int `json:"revision"`
|
||
}
|
||
readJSON(t, rec.Body.Bytes(), &snap)
|
||
|
||
act := func(body string) *httptest.ResponseRecorder {
|
||
req := httptest.NewRequest(http.MethodPost, "/api/surfaces/"+snap.Identity.SurfaceID+"/actions", bytes.NewBufferString(body))
|
||
req.Header.Set("Authorization", "Bearer "+key)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
rec := httptest.NewRecorder()
|
||
h.ServeHTTP(rec, req)
|
||
return rec
|
||
}
|
||
// 未知动作 → 400
|
||
if r := act(`{"actionId":"shell.exec","revision":` + fmt.Sprint(snap.Revision) + `,"requestId":"r1","data":{}}`); r.Code != http.StatusBadRequest {
|
||
t.Fatalf("unknown action should 400, got %d: %s", r.Code, r.Body.String())
|
||
}
|
||
// 伪造 confirmed 字段:请求体里带 confirmed:true 不产生任何效果(端点不绑定
|
||
// 该字段;DangerHigh 确认只能经确认卡回道)。此处 submit 非 DangerHigh,
|
||
// 断言 confirmed 被忽略(成功且不因它改变行为)。
|
||
if r := act(`{"actionId":"generative-ui.submit","revision":` + fmt.Sprint(snap.Revision) + `,"requestId":"r2","data":{"a":"1"},"confirmed":true}`); r.Code != http.StatusOK {
|
||
t.Fatalf("submit with forged confirmed should still be normal submit, got %d: %s", r.Code, r.Body.String())
|
||
}
|
||
// 禁用后 → 403
|
||
svc.SetActivation(ctx, "alice", "generative-ui", false)
|
||
if r := act(`{"actionId":"generative-ui.submit","revision":` + fmt.Sprint(snap.Revision) + `,"requestId":"r3","data":{}}`); r.Code != http.StatusForbidden {
|
||
t.Fatalf("post-disable action should 403, got %d", r.Code)
|
||
}
|
||
_ = fmt.Sprint
|
||
}
|
||
|
||
// 新建页面必须绑定调用者自有会话:会话表是项目归属的唯一来源,空会话会让页面
|
||
// 绕过项目生命周期检查(U6)。空 → 400;缺失与他人会话同回 404(不泄露存在性);
|
||
// 自有会话 → 201 且身份带上该会话的项目。
|
||
func TestSurfaceSubmitConversationRequired(t *testing.T) {
|
||
srv, st := newAuthTestServer(t)
|
||
h := srv.Handler()
|
||
key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
svc := surfacesvc.New(st, nil)
|
||
srv.Surfaces = svc
|
||
if _, err := svc.SetActivation(context.Background(), "alice", "genui", true); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
conv := surfaceConversationFixture(t, st, "alice")
|
||
foreign := surfaceConversationFixture(t, st, "bob")
|
||
spec := `"spec":{"title":"t","nodes":[{"type":"markdown","id":"n1","markdown":"x"}]}`
|
||
post := func(body string) *httptest.ResponseRecorder {
|
||
req := httptest.NewRequest(http.MethodPost, "/api/surfaces", bytes.NewBufferString(body))
|
||
req.Header.Set("Authorization", "Bearer "+key)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
rec := httptest.NewRecorder()
|
||
h.ServeHTTP(rec, req)
|
||
return rec
|
||
}
|
||
if rec := post(`{"pluginId":"genui",` + spec + `}`); rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("empty conversationId should 400, got %d: %s", rec.Code, rec.Body.String())
|
||
}
|
||
if rec := post(`{"pluginId":"genui","conversationId":"cnv_missing",` + spec + `}`); rec.Code != http.StatusNotFound {
|
||
t.Fatalf("unknown conversationId should 404, got %d: %s", rec.Code, rec.Body.String())
|
||
}
|
||
if rec := post(`{"pluginId":"genui","conversationId":"` + foreign.ID + `",` + spec + `}`); rec.Code != http.StatusNotFound {
|
||
t.Fatalf("foreign conversationId should 404, got %d: %s", rec.Code, rec.Body.String())
|
||
}
|
||
rec := post(`{"pluginId":"genui","conversationId":"` + conv.ID + `",` + spec + `}`)
|
||
if rec.Code != http.StatusCreated {
|
||
t.Fatalf("caller conversation submit should 201, got %d: %s", rec.Code, rec.Body.String())
|
||
}
|
||
var snap struct {
|
||
Identity struct {
|
||
ConversationID string `json:"conversationId"`
|
||
ProjectID string `json:"projectId"`
|
||
} `json:"identity"`
|
||
}
|
||
readJSON(t, rec.Body.Bytes(), &snap)
|
||
if snap.Identity.ConversationID != conv.ID {
|
||
t.Fatalf("conversationId should be %q, got %q", conv.ID, snap.Identity.ConversationID)
|
||
}
|
||
if snap.Identity.ProjectID != conv.ProjectID {
|
||
t.Fatalf("projectId should come from conversation: want %q, got %q", conv.ProjectID, snap.Identity.ProjectID)
|
||
}
|
||
}
|