Files
Agent-System-Backend/internal/server/surface_api_test.go
T
agent-exec-transportandCursor 954e744d6d
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m14s
Require the caller's conversation when creating a surface
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>
2026-10-21 15:02:15 +00:00

223 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}