Merge pull request 'Require the caller's conversation when creating a surface' (#239) from fix/surface-conversation-required into main
governance / governance (push) Successful in 13s
ci / vet-and-test (push) Successful in 1m13s

This commit was merged in pull request #239.
This commit is contained in:
2026-10-21 15:05:03 +00:00
2 changed files with 90 additions and 5 deletions
+12
View File
@@ -210,6 +210,18 @@ func (s *Server) submitSurface(w http.ResponseWriter, r *http.Request) {
http.Error(w, "pluginId and spec required", http.StatusBadRequest)
return
}
// 新建页面必须落在调用者自己的会话上:会话表反查项目归属是唯一来源,
// 空或越权会话会让页面绕过项目生命周期检查。缺失与他人会话同回 404。
if body.SurfaceID == "" {
if body.ConversationID == "" {
http.Error(w, "conversationId required", http.StatusBadRequest)
return
}
if _, err := s.st.GetConversation(s.owner(r), body.ConversationID); err != nil {
http.Error(w, "conversation not found", http.StatusNotFound)
return
}
}
snap, err := s.Surfaces.SubmitAccountChecked(r.Context(), s.owner(r), body.PluginID, surfacesvc.SubmitRequest{
SurfaceID: core.ID(body.SurfaceID), ConversationID: core.ID(body.ConversationID),
BaseRevision: body.BaseRevision, ObservedEpoch: body.ObservedEpoch,
+78 -5
View File
@@ -12,9 +12,26 @@ import (
"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()
@@ -24,6 +41,7 @@ func TestSurfaceDirectAPIGuards(t *testing.T) {
}
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))
@@ -43,7 +61,7 @@ func TestSurfaceDirectAPIGuards(t *testing.T) {
spec := `"spec":{"title":"t","nodes":[{"type":"markdown","id":"n1","markdown":"x"}]}`
// 未启用:提交被拒(403),提示能力存在但插件未启用
if rec := post("/api/surfaces", `{"pluginId":"genui","conversationId":"c1",`+spec+`}`); rec.Code != http.StatusForbidden {
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)
@@ -51,7 +69,7 @@ func TestSurfaceDirectAPIGuards(t *testing.T) {
t.Fatalf("enable failed: %d %s", rec.Code, rec.Body.String())
}
// 提交成功 → 201
rec := post("/api/surfaces", `{"pluginId":"genui","conversationId":"c1",`+spec+`}`)
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())
}
@@ -67,14 +85,14 @@ func TestSurfaceDirectAPIGuards(t *testing.T) {
if rec := get("/api/surfaces/" + snap.Identity.SurfaceID); rec.Code != http.StatusOK {
t.Fatalf("get failed: %d", rec.Code)
}
if rec := get("/api/conversations/c1/surfaces"); rec.Code != http.StatusOK {
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":"c1",`+spec+`}`); rec.Code != http.StatusForbidden {
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 {
@@ -100,11 +118,12 @@ func TestSurfaceActionEndpoint(t *testing.T) {
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":"c1",`+spec+`}`))
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)
@@ -147,3 +166,57 @@ func TestSurfaceActionEndpoint(t *testing.T) {
}
_ = 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)
}
}