Files
Agent-System-Backend/internal/surfacesvc/actions_test.go
T
weichen f1302ed5b3 Enable canonical versioned Go module consumption
Why: Allow consumers to download an immutable module revision without nested source checkouts or local path replacements.

Evidence: SELF package tests and the prior canonical-path rejection are recorded in the Dev dependency-workspace task card; remote consumption is replayed against the committed candidate.

Impact: Change the module identity and its Go imports while preserving API behavior. Existing historical submodule revisions remain buildable.

Refs: Versioned dependencies U1/U3; feature/dependency-workspace
2026-10-13 15:47:16 +00:00

268 lines
11 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 surfacesvc
// U5 红测(已批准范围 r2 验收线):启停守卫、旧修订拒绝、白名单拒绝、
// 幂等同参回放/异参拒绝、DangerHigh 经确认卡且执行只由卡回道触发。
// 数据准备走真实 surfacesvc.Submit(与生产同路径)。
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"testing"
"git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/store"
core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git"
)
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 TestAllowedActionsIncludesBClassHelpAndIntent(t *testing.T) {
for _, id := range []string{"builtin.help", "system.intent", "generative-ui.submit"} {
if cmd, ok := AllowedActions[id]; !ok || cmd != id {
t.Fatalf("AllowedActions missing 1:1 binding for %q: %q ok=%v", id, cmd, ok)
}
}
}
func TestSubmitActionBClassHelpDispatches(t *testing.T) {
f := newActionFixture(t)
ctx := context.Background()
var got string
f.svc.SetDispatcher(func(ctx context.Context, d ActionDispatch) (ActionOutcome, error) {
got = d.CommandID
return ActionOutcome{State: "committed", Payload: map[string]any{"reply": "ok"}}, nil
})
res, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
ActionID: "builtin.help", Revision: f.rev, RequestID: "help-1", Data: map[string]any{}})
if err != nil || res.State != "committed" || got != "builtin.help" {
t.Fatalf("help action: got=%q res=%+v err=%v", got, res, err)
}
}
func TestSubmitActionFlattensDataIntoParams(t *testing.T) {
f := newActionFixture(t)
ctx := context.Background()
var params map[string]any
f.svc.SetDispatcher(func(ctx context.Context, d ActionDispatch) (ActionOutcome, error) {
params = d.Params
return ActionOutcome{State: "committed", Payload: map[string]any{"matched": false}}, nil
})
res, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
ActionID: "system.intent", Revision: f.rev, RequestID: "intent-1",
Data: map[string]any{"text": "请新建会话"}})
if err != nil || res.State != "committed" {
t.Fatalf("intent action: res=%+v err=%v", res, err)
}
if params["text"] != "请新建会话" {
t.Fatalf("data.text must flatten to top-level params for ParamsSchema: %+v", params)
}
data, _ := params["data"].(map[string]any)
if data["text"] != "请新建会话" {
t.Fatalf("nested data must remain for submit-shaped consumers: %+v", params)
}
}
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
}