ci / vet-and-test (pull_request) Successful in 54s
Why: U6 extends pages from conversations into the task/project
workspace - surfaces aggregate across conversations under the owner's
project scope (project resolved authoritatively from the conversation
row, never self-reported by the model), and the catalog guidance now
organizes pages by task phase (requirements/execution/acceptance)
while stating that task facts come only from real task records.
Evidence: build/vet clean; surfacesvc suite green incl. the new
project-aggregation test (owner isolation, cross-owner zero, history
retained post-disable); genui/store suites green.
Impact: New endpoint GET /api/projects/{id}/surfaces; Submit resolves
project from conversation; catalog text guidance. Existing endpoints
unchanged.
Refs: proposal r2 U6; Dev task feature/generative-ui-u6
222 lines
9.3 KiB
Go
222 lines
9.3 KiB
Go
package surfacesvc
|
||
|
||
// U5 红测(已批准范围 r2 验收线):启停守卫、旧修订拒绝、白名单拒绝、
|
||
// 幂等同参回放/异参拒绝、DangerHigh 经确认卡且执行只由卡回道触发。
|
||
// 数据准备走真实 surfacesvc.Submit(与生产同路径)。
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"path/filepath"
|
||
"testing"
|
||
|
||
"agent-system-backend/internal/store"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
type actionFixture struct {
|
||
svc *Service
|
||
st *store.Store
|
||
surface core.SurfaceIdentity
|
||
rev int
|
||
projectID string
|
||
}
|
||
|
||
func newActionFixtureWithConversation(t *testing.T, projectID string) *actionFixture {
|
||
t.Helper()
|
||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { st.Close() })
|
||
svc := New(st, nil)
|
||
ctx := context.Background()
|
||
if _, err := svc.SetActivation(ctx, "alice", "generative-ui", true); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
pj, err := st.CreateProject("alice", "U6测试项目")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
projectID = string(pj.ID)
|
||
if _, err := st.DB().Exec(`INSERT INTO conversations(id,project_id,owner,title,created_at,updated_at) VALUES(?,?,?,'U6会话',datetime('now'),datetime('now'))`,
|
||
"c1", projectID, "alice"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
snap, err := svc.Submit(ctx, "alice", "generative-ui", SubmitRequest{
|
||
ConversationID: "c1",
|
||
Spec: json.RawMessage(`{"title":"需求表","nodes":[{"type":"form","id":"f1","fields":[{"key":"years","label":"年限","type":"select","options":["3","5"]}]},{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"提交"}]}]}`),
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return &actionFixture{svc: svc, st: st, surface: snap.Identity, rev: snap.Revision, projectID: projectID}
|
||
}
|
||
|
||
func newActionFixture(t *testing.T) *actionFixture {
|
||
t.Helper()
|
||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { st.Close() })
|
||
svc := New(st, nil)
|
||
ctx := context.Background()
|
||
if _, err := svc.SetActivation(ctx, "alice", "generative-ui", true); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
snap, err := svc.Submit(ctx, "alice", "generative-ui", SubmitRequest{
|
||
ConversationID: "c1",
|
||
Spec: json.RawMessage(`{"title":"需求表","nodes":[{"type":"form","id":"f1","fields":[{"key":"years","label":"年限","type":"select","options":["3","5"]}]},{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"提交"}]}]}`),
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return &actionFixture{svc: svc, st: st, surface: snap.Identity, rev: snap.Revision}
|
||
}
|
||
|
||
func TestSubmitActionGuards(t *testing.T) {
|
||
f := newActionFixture(t)
|
||
ctx := context.Background()
|
||
// 白名单外动作 → 拒绝
|
||
if _, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
|
||
ActionID: "shell.exec", Revision: f.rev, RequestID: "r0", Data: map[string]any{}}); !errors.Is(err, ErrUnknownAction) {
|
||
t.Fatalf("unknown action should reject, got %v", err)
|
||
}
|
||
// 旧修订 → 冲突(带当前版本)
|
||
_, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
|
||
ActionID: "generative-ui.submit", Revision: f.rev - 1, RequestID: "r0b", Data: map[string]any{}})
|
||
var conflict *store.ErrRevisionConflict
|
||
if !errors.As(err, &conflict) || conflict.Current != f.rev {
|
||
t.Fatalf("stale revision should conflict with current=%d, got %v", f.rev, err)
|
||
}
|
||
// 跨 owner → NotFound
|
||
if _, err := f.svc.SubmitAction(ctx, "bob", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
|
||
ActionID: "generative-ui.submit", Revision: f.rev, RequestID: "r0c", Data: map[string]any{}}); !errors.Is(err, ErrNotFound) {
|
||
t.Fatalf("cross-owner should be NotFound, got %v", err)
|
||
}
|
||
// 未装配 dispatcher → 能力不可用
|
||
if _, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
|
||
ActionID: "generative-ui.submit", Revision: f.rev, RequestID: "r0d", Data: map[string]any{}}); !errors.Is(err, ErrUnavailable) {
|
||
t.Fatalf("no dispatcher should be unavailable, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestSubmitActionIdempotency(t *testing.T) {
|
||
f := newActionFixture(t)
|
||
ctx := context.Background()
|
||
var calls int
|
||
f.svc.SetDispatcher(func(ctx context.Context, d ActionDispatch) (ActionOutcome, error) {
|
||
calls++
|
||
return ActionOutcome{State: "committed", Receipt: map[string]any{"op": "op_1"}}, nil
|
||
})
|
||
data := map[string]any{"years": "5"}
|
||
r1, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
|
||
ActionID: "generative-ui.submit", Revision: f.rev, RequestID: "req-1", Data: data})
|
||
if err != nil || r1.State != "committed" {
|
||
t.Fatalf("first submit: %+v err=%v", r1, err)
|
||
}
|
||
// 同 requestId 同参数 → 回放已有回执,不再分发
|
||
r2, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
|
||
ActionID: "generative-ui.submit", Revision: f.rev, RequestID: "req-1", Data: data})
|
||
if err != nil || r2.State != "committed" || calls != 1 {
|
||
t.Fatalf("idempotent replay must not redispatch: calls=%d r2=%+v err=%v", calls, r2, err)
|
||
}
|
||
// 同 requestId 异参数 → 拒绝
|
||
if _, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
|
||
ActionID: "generative-ui.submit", Revision: f.rev, RequestID: "req-1",
|
||
Data: map[string]any{"years": "3"}}); !errors.Is(err, store.ErrIdempotencyConflict) {
|
||
t.Fatalf("same requestId different params must reject, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestSubmitActionDisabledAfterEnable(t *testing.T) {
|
||
f := newActionFixture(t)
|
||
ctx := context.Background()
|
||
f.svc.SetDispatcher(func(ctx context.Context, d ActionDispatch) (ActionOutcome, error) {
|
||
return ActionOutcome{State: "committed"}, nil
|
||
})
|
||
// 禁用后:新动作拒绝(KTD7:禁用返回即准入截止点)
|
||
if _, err := f.svc.SetActivation(ctx, "alice", "generative-ui", false); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
|
||
ActionID: "generative-ui.submit", Revision: f.rev, RequestID: "r9", Data: map[string]any{}}); !errors.Is(err, ErrDisabled) {
|
||
t.Fatalf("post-disable action should reject, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestDangerHighGoesThroughConfirmCard(t *testing.T) {
|
||
f := newActionFixture(t)
|
||
ctx := context.Background()
|
||
// 模拟把一条 DangerHigh 命令加入白名单(B 类机制验证;正式白名单由独立评审加)。
|
||
prev := AllowedActions["generative-ui.submit"]
|
||
saved := map[string]string{"generative-ui.submit": "generative-ui.submit"}
|
||
_ = prev
|
||
AllowedActions["test.highwrite"] = "test.highwrite"
|
||
defer func() {
|
||
delete(AllowedActions, "test.highwrite")
|
||
_ = saved
|
||
}()
|
||
var executed int
|
||
f.svc.SetDispatcher(func(ctx context.Context, d ActionDispatch) (ActionOutcome, error) {
|
||
// 模拟总线行为:DangerHigh 未确认 → 创建确认卡、不执行(真实路径在
|
||
// cmdbus 红测;此处验证 surfacesvc 不透传 Confirmed 的等价语义)。
|
||
if d.Params["confirmed"] == true {
|
||
executed++
|
||
return ActionOutcome{State: "committed"}, nil
|
||
}
|
||
return ActionOutcome{State: "awaiting_confirm", CardID: "card_1"}, nil
|
||
})
|
||
res, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
|
||
ActionID: "test.highwrite", Revision: f.rev, RequestID: "req-h", Data: map[string]any{"x": 1}})
|
||
if err != nil || res.State != "awaiting_confirm" || res.CardID == "" {
|
||
t.Fatalf("danger-high must await confirm card: %+v err=%v", res, err)
|
||
}
|
||
if executed != 0 {
|
||
t.Fatal("danger-high must not execute without card confirm")
|
||
}
|
||
// 重放同 requestId: awaiting_confirm 态原样返回(未执行的确认仍挂在卡上)
|
||
res2, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
|
||
ActionID: "test.highwrite", Revision: f.rev, RequestID: "req-h", Data: map[string]any{"x": 1}})
|
||
if err != nil || res2.State != "awaiting_confirm" {
|
||
t.Fatalf("replay should return awaiting_confirm, got %+v err=%v", res2, err)
|
||
}
|
||
}
|
||
|
||
func TestProjectAggregationOwnerIsolation(t *testing.T) {
|
||
// U6/R8 验收:项目级跨会话聚合按 owner 隔离(跨 owner 不可见);
|
||
// 禁用不阻断读取(历史页面保留)。
|
||
f := newActionFixtureWithConversation(t, "prj_u6")
|
||
ctx := context.Background()
|
||
snap2, err := f.svc.Submit(ctx, "alice", "generative-ui", SubmitRequest{
|
||
ConversationID: "c1",
|
||
Spec: json.RawMessage(`{"title":"执行进度页","nodes":[{"type":"steps","id":"s1","steps":{"current":1,"steps":[{"label":"需求冻结","status":"done"},{"label":"实施","status":"active"}]}}]}`),
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
metas, err := f.svc.ListByProject(ctx, "alice", core.ID(f.projectID))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(metas) != 2 {
|
||
t.Fatalf("alice should see her project surfaces, got %+v", metas)
|
||
}
|
||
// 跨 owner 隔离:bob 看不到 alice 的项目页面
|
||
bobMetas, err := f.svc.ListByProject(ctx, "bob", core.ID(f.projectID))
|
||
if err != nil || len(bobMetas) != 0 {
|
||
t.Fatalf("bob must see zero surfaces, got %+v err=%v", bobMetas, err)
|
||
}
|
||
// 禁用后读取保留
|
||
if _, err := f.svc.SetActivation(ctx, "alice", "generative-ui", false); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
after, err := f.svc.ListByProject(ctx, "alice", core.ID(f.projectID))
|
||
if err != nil || len(after) != 2 {
|
||
t.Fatalf("post-disable read must retain history, got %+v err=%v", after, err)
|
||
}
|
||
_ = snap2
|
||
}
|