Files
Agent-System-Backend/internal/cmdbus/cmdbus_test.go
T
weichen 14e780c6fa Bind formal commands to exact project sources
Why: Chat intent, cross-conversation anchors, payload changes, and native workspace tools could bypass project source analysis.

Evidence: Command-bus, app, server, and squad-adapter suites pass with centralized preflight and project-tool fail-closed behavior.

Impact: Every classified formal entrance binds actor, conversation, project, command, payload digest, and source watermark before dispatch.

Refs: TT-N03 TT-R01
2026-10-15 07:00:18 +00:00

345 lines
14 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 cmdbus
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
agentcommand "agent-command"
"agent-system-core"
)
// memAudit / memCards 测试替身。
type memAudit struct {
mu sync.Mutex
logs []agentcommand.AuditEnvelope
fail bool
}
func (m *memAudit) AppendAudit(owner string, env agentcommand.AuditEnvelope) error {
if m.fail {
return errors.New("audit down")
}
m.mu.Lock()
defer m.mu.Unlock()
m.logs = append(m.logs, env)
return nil
}
type memCards struct{ created []core.ActivityCard }
func (m *memCards) Create(owner string, c core.ActivityCard, ref string) (core.ActivityCard, error) {
c.ID = core.ID("card_x1")
m.created = append(m.created, c)
return c, nil
}
func (m *memCards) SetStatus(owner, id string, st core.CardStatus, tone, note string) (core.ActivityCard, error) {
return core.ActivityCard{ID: core.ID(id), Status: st}, nil
}
func newTestBus(a AuditStore, c CardPort) *Bus {
b := New(a, c, func() time.Time { return time.Unix(1700000000, 0).UTC() })
_ = b.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.ping", Title: "Ping"}, "/ping",
func(ctx context.Context, req Request) (any, error) { return map[string]any{"pong": true}, nil })
_ = b.RegisterBuiltin(agentcommand.CommandSpec{
ID: "demo.risky", Title: "危险演示", Danger: agentcommand.DangerHigh,
Description: "高危命令演示",
ParamsSchema: json.RawMessage(`{"type":"object","required":["target"],"properties":{"target":{"type":"string"}}}`),
}, "/risky", func(ctx context.Context, req Request) (any, error) {
t, _ := req.Params["target"].(string)
if t == "boom" {
return nil, errors.New("handler rejected")
}
return map[string]any{"did": t}, nil
})
return b
}
func TestDispatchFourEntrancesSameAudit(t *testing.T) {
a := &memAudit{}
b := newTestBus(a, &memCards{})
b.SetProjectWorkPreflight(func(context.Context, Request) error { return nil })
for _, ent := range []string{"button", "slash", "chat", "card", "api", "weird-unknown"} {
resp := b.Dispatch(context.Background(), Request{ID: "demo.ping", Entrance: ent})
if resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("entrance %s: state=%s reason=%s", ent, resp.Receipt.State, resp.Receipt.Reason)
}
}
if len(a.logs) != 6 {
t.Fatalf("audit logs = %d, want 6", len(a.logs))
}
want := map[string]string{"button": "button", "slash": "slash", "chat": "chat", "card": "card", "api": "api", "weird-unknown": "api"}
for i, env := range a.logs {
if env.Source != agentcommand.SourceBuiltin {
t.Fatalf("log %d source = %s", i, env.Source)
}
}
_ = want
// 入口归一:未知名归 api
if got := a.logs[5].Entrance; got != "api" {
t.Fatalf("unknown entrance canonicalized to %s, want api", got)
}
if a.logs[0].OperationID == "" {
t.Fatal("committed receipt must carry operationID")
}
}
func TestHandlerReceivesCommittedOperationIDBeforeDomainWrite(t *testing.T) {
audit := &memAudit{}
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 123).UTC() })
var seen string
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "rdm.confirm", Title: "Confirm"}, "", func(_ context.Context, req Request) (any, error) {
seen = req.OperationID
return "committed-domain-event", nil
}); err != nil {
t.Fatal(err)
}
resp := bus.Dispatch(context.Background(), Request{ID: "rdm.confirm", Actor: "alice", Owner: "alice", Params: map[string]any{"operationId": "forged-client-id"}})
if seen == "" || seen == "forged-client-id" || resp.Receipt.OperationID != seen || len(audit.logs) != 1 || audit.logs[0].OperationID != seen {
t.Fatalf("operation correlation missing: handler=%q receipt=%+v audit=%+v", seen, resp.Receipt, audit.logs)
}
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "rdm.fail", Title: "Fail"}, "", func(_ context.Context, req Request) (any, error) {
if req.OperationID == "" {
t.Fatal("handler must receive a tentative host operation ID")
}
return nil, errors.New("domain transaction rejected")
}); err != nil {
t.Fatal(err)
}
failed := bus.Dispatch(context.Background(), Request{ID: "rdm.fail", Actor: "alice", Owner: "alice"})
if failed.Receipt.State != agentcommand.StateRejected || failed.Receipt.OperationID != "" || len(audit.logs) != 2 || audit.logs[1].OperationID != "" {
t.Fatalf("failed handler was marked committed: %+v audits=%+v", failed.Receipt, audit.logs)
}
}
func TestOperationIDsRemainUniqueWithFixedClockAndConcurrentDispatch(t *testing.T) {
audit := &memAudit{}
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 123).UTC() })
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "rdm.confirm", Title: "Confirm"}, "", func(_ context.Context, req Request) (any, error) {
return req.OperationID, nil
}); err != nil {
t.Fatal(err)
}
const count = 64
var wg sync.WaitGroup
ids := make([]string, count)
for i := range ids {
wg.Add(1)
go func(i int) {
defer wg.Done()
resp := bus.Dispatch(context.Background(), Request{ID: "rdm.confirm", Actor: "alice", Owner: "alice"})
ids[i] = resp.Receipt.OperationID
}(i)
}
wg.Wait()
seen := map[string]bool{}
for _, id := range ids {
if id == "" || seen[id] {
t.Fatalf("fixed clock produced a missing or duplicate trusted operation ID: %q", id)
}
seen[id] = true
}
if len(audit.logs) != count {
t.Fatalf("audit rows=%d want=%d", len(audit.logs), count)
}
}
func TestDispatchUnknownAndBadParams(t *testing.T) {
a := &memAudit{}
b := newTestBus(a, &memCards{})
resp := b.Dispatch(context.Background(), Request{ID: "nope.x"})
if resp.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp.Receipt.Reason, "unknown") {
t.Fatalf("unknown: %+v", resp.Receipt)
}
resp = b.Dispatch(context.Background(), Request{ID: "demo.risky", Confirmed: true, Params: map[string]any{}})
if resp.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp.Receipt.Reason, "required param target") {
t.Fatalf("missing required: %+v", resp.Receipt)
}
}
func TestHighDangerConfirmFlow(t *testing.T) {
a := &memAudit{}
cards := &memCards{}
b := newTestBus(a, cards)
// 未确认:创建内联确认卡,回执 accepted + operationID=卡 ID。
resp := b.Dispatch(context.Background(), Request{ID: "demo.risky", Params: map[string]any{"target": "prod"}, Entrance: "button", ConversationID: "cnv_1", AnchorMessageID: "msg_1"})
if resp.Receipt.State != agentcommand.StateAccepted || resp.Receipt.OperationID != "card_x1" {
t.Fatalf("pre-confirm: %+v", resp.Receipt)
}
if resp.Card == nil || resp.Card.Kind != core.CardKindConfirm || resp.Card.Status != core.CardActionRequired {
t.Fatalf("confirm card not created properly: %+v", resp.Card)
}
if resp.Card.ConversationID != "cnv_1" || resp.Card.AnchorMessageID == "" {
t.Fatalf("card anchoring missing: %+v", resp.Card)
}
// 确认:执行挂起请求,committed。
resp2 := b.Confirm("owner", "card_x1", "confirm")
if resp2.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("confirm: %+v", resp2.Receipt)
}
if m, _ := resp2.Payload.(map[string]any); m == nil || m["did"] != "prod" {
t.Fatalf("confirm payload: %+v", resp2.Payload)
}
// 二次确认(已消费):rejected expired。
resp3 := b.Confirm("owner", "card_x1", "confirm")
if resp3.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp3.Receipt.Reason, "expired") {
t.Fatalf("re-confirm: %+v", resp3.Receipt)
}
// 审计链:accepted(button) → committed(card);过期确认未触碰命令,不产生审计。
if len(a.logs) != 2 {
t.Fatalf("audit logs = %d, want 2", len(a.logs))
}
if a.logs[0].State != agentcommand.StateAccepted || a.logs[0].Entrance != "button" ||
a.logs[1].State != agentcommand.StateCommitted || a.logs[1].Entrance != "card" {
t.Fatalf("audit chain wrong: %+v", a.logs)
}
}
func TestHighDangerCancelAndHandlerReject(t *testing.T) {
a := &memAudit{}
b := newTestBus(a, &memCards{})
b.Dispatch(context.Background(), Request{ID: "demo.risky", Params: map[string]any{"target": "x"}, ConversationID: "cnv_1"})
if r := b.Confirm("o", "card_x1", "cancel"); r.Receipt.State != agentcommand.StateRejected || !strings.Contains(r.Receipt.Reason, "cancelled") {
t.Fatalf("cancel: %+v", r.Receipt)
}
// handler 拒绝路径(确认后执行失败)→ rejected + 理由。
b.Dispatch(context.Background(), Request{ID: "demo.risky", Params: map[string]any{"target": "boom"}, ConversationID: "cnv_1"})
r := b.Confirm("o", "card_x1", "confirm")
if r.Receipt.State != agentcommand.StateRejected || !strings.Contains(r.Receipt.Reason, "handler rejected") {
t.Fatalf("handler reject: %+v", r.Receipt)
}
}
func TestHighDangerWithoutCardPortRejected(t *testing.T) {
b := newTestBus(&memAudit{}, nil)
resp := b.Dispatch(context.Background(), Request{ID: "demo.risky", Params: map[string]any{"target": "x"}, ConversationID: "cnv_1"})
if resp.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp.Receipt.Reason, "conversation context") {
t.Fatalf("no card port: %+v", resp.Receipt)
}
}
func TestAuditUnavailableYieldsUnknown(t *testing.T) {
a := &memAudit{fail: true}
b := newTestBus(a, &memCards{})
resp := b.Dispatch(context.Background(), Request{ID: "demo.ping"})
if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "audit unavailable") {
t.Fatalf("audit down: %+v", resp.Receipt)
}
}
func TestPluginCommandsAndSlash(t *testing.T) {
b := newTestBus(&memAudit{}, &memCards{})
if err := b.RegisterPluginCommands([]json.RawMessage{json.RawMessage(`{"id":"plug.do","title":"插件命令"}`)}); err != nil {
t.Fatal(err)
}
if err := b.RegisterPluginCommands([]json.RawMessage{json.RawMessage(`{"id":"bad id","title":"x"}`)}); err == nil {
t.Fatal("invalid plugin spec must be rejected")
}
if id, ok := b.ResolveSlash("/risky"); !ok || id != "demo.risky" {
t.Fatalf("slash resolve: %s %v", id, ok)
}
if _, ok := b.ResolveSlash("/nope"); ok {
t.Fatal("unknown slash must not resolve")
}
if !b.HasCommand("plug.do") || b.HasCommand("plug.missing") {
t.Fatal("HasCommand wrong")
}
}
func TestValidateParamsScalars(t *testing.T) {
schema := json.RawMessage(`{"type":"object","required":["n"],"properties":{"n":{"type":"number"},"s":{"type":"string"},"b":{"type":"boolean"},"i":{"type":"integer"}}}`)
if err := validateParams(schema, map[string]any{"n": 1.5, "s": "x", "b": true, "i": 3}); err != nil {
t.Fatalf("valid params rejected: %v", err)
}
if err := validateParams(schema, map[string]any{}); err == nil || !strings.Contains(err.Error(), "required") {
t.Fatalf("missing required not caught: %v", err)
}
if err := validateParams(schema, map[string]any{"n": "not-a-number"}); err == nil {
t.Fatal("type mismatch not caught")
}
if err := validateParams(schema, map[string]any{"n": 1, "i": 2.5}); err == nil {
t.Fatal("integer fraction not caught")
}
if err := validateParams(nil, map[string]any{"any": 1}); err != nil {
t.Fatalf("nil schema must pass: %v", err)
}
}
// ---- EXT-1:进程内插件命令处理器 ----
func TestRegisterPluginCommandDispatch(t *testing.T) {
a := &memAudit{}
b := newTestBus(a, &memCards{})
b.SetProjectWorkPreflight(func(context.Context, Request) error { return nil })
err := b.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.ingest", Title: "入库"}, func(ctx context.Context, req Request) (any, error) {
return map[string]any{"echo": req.Params["ref"], "owner": req.Owner}, nil
})
if err != nil {
t.Fatal(err)
}
resp := b.Dispatch(context.Background(), Request{ID: "rdm.ingest", Params: map[string]any{"ref": "doc-1"}, Owner: "alice"})
if resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("dispatch: %+v", resp.Receipt)
}
m, _ := resp.Payload.(map[string]any)
if m["echo"] != "doc-1" || m["owner"] != "alice" {
t.Fatalf("payload: %+v", resp.Payload)
}
if len(a.logs) != 1 || a.logs[0].Source != agentcommand.SourcePlugin {
t.Fatalf("audit: %+v", a.logs)
}
// 重复注册同一 ID:fail-closed。
if err := b.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.ingest", Title: "重复"}, nil); err == nil {
t.Fatal("duplicate plugin command must be rejected")
}
}
func TestPluginCommandWithoutHandlerRejected(t *testing.T) {
b := newTestBus(&memAudit{}, &memCards{})
// 声明式路径只登记规格(manifest provides.commands)。
if err := b.RegisterPluginCommands([]json.RawMessage{json.RawMessage(`{"id":"plug.only","title":"仅规格"}`)}); err != nil {
t.Fatal(err)
}
resp := b.Dispatch(context.Background(), Request{ID: "plug.only"})
if resp.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp.Receipt.Reason, "no host handler bound") {
t.Fatalf("unbound: %+v", resp.Receipt)
}
}
func TestHighDangerPluginCommandConfirmsFirst(t *testing.T) {
a := &memAudit{}
cards := &memCards{}
b := newTestBus(a, cards)
executed := 0
err := b.RegisterPluginCommand(agentcommand.CommandSpec{
ID: "rdm.purge", Title: "清库", Danger: agentcommand.DangerHigh,
}, func(ctx context.Context, req Request) (any, error) {
executed++
return "purged", nil
})
if err != nil {
t.Fatal(err)
}
// 未确认:先出内联确认卡,处理器不得执行。
resp := b.Dispatch(context.Background(), Request{ID: "rdm.purge", ConversationID: "cnv_9", Owner: "alice"})
if resp.Receipt.State != agentcommand.StateAccepted || resp.Card == nil || resp.Card.Kind != core.CardKindConfirm {
t.Fatalf("pre-confirm: %+v card=%+v", resp.Receipt, resp.Card)
}
if executed != 0 {
t.Fatal("handler must not run before confirm")
}
// 确认后执行 committed。
resp2 := b.Confirm("alice", "card_x1", "confirm")
if resp2.Receipt.State != agentcommand.StateCommitted || resp2.Payload != "purged" || executed != 1 {
t.Fatalf("confirm: %+v executed=%d", resp2.Receipt, executed)
}
}