Why: F10 - any handler error became a no-side-effect rejected receipt and an audit-store failure dropped the generated operation ID, losing the reconciliation handle and inviting unsafe retries. Evidence: cmdbus tests pass (verify-u7b.json); post-execution-unknown, audit-failure-keeps-ID and confirmation-idempotency cases added. Impact: unknown receipts now carry the trusted operation ID via the U7A constructor; rejected stays ID-free; no auto-replay anywhere. Refs: feature/agent-stack-audit-remediation-b1 U7B
598 lines
26 KiB
Go
598 lines
26 KiB
Go
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" {
|
||
// 参数级拒绝发生在任何副作用之前:显式声明 not-executed(F10)。
|
||
return nil, NotExecuted(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")
|
||
}
|
||
// 领域事务回滚 = 确定未执行:声明 not-executed 才是 rejected 的依据。
|
||
return nil, NotExecuted(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("not-executed handler was not rejected: %+v audits=%+v", failed.Receipt, audit.logs)
|
||
}
|
||
if err := failed.Receipt.Validate(); err != nil {
|
||
t.Fatalf("rejected receipt must satisfy the library contract: %v", err)
|
||
}
|
||
if failed.Receipt.SideEffects != agentcommand.SideEffectsNone {
|
||
t.Fatalf("rejected receipt must declare no side effects, got %q", failed.Receipt.SideEffects)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
// ---- F10/U7b:确定未执行 vs 已执行后不确定 + operationID 对账 ----
|
||
|
||
// memOperationQuery 是按 owner/project 等值谓词过滤的操作记录替身(模拟 SQL
|
||
// 作用域):跨 owner 或跨 project 的 operationID 一律查不到。
|
||
type memOperationQuery struct {
|
||
records map[string]OperationRecord // key: owner|projectID|operationID
|
||
}
|
||
|
||
func operationKey(owner, projectID, operationID string) string {
|
||
return owner + "|" + projectID + "|" + operationID
|
||
}
|
||
|
||
func (q *memOperationQuery) LookupOperation(owner, projectID, operationID string) (OperationRecord, bool, error) {
|
||
rec, ok := q.records[operationKey(owner, projectID, operationID)]
|
||
return rec, ok, nil
|
||
}
|
||
|
||
// recordAudit 把总线真实落下的审计信封登记为可查操作记录(对齐"审计信封就是
|
||
// 宿主侧操作记录"的口径),用于验证 unknown 回执给出的对账句柄真的查得到。
|
||
func (q *memOperationQuery) recordAudit(owner, projectID string, env agentcommand.AuditEnvelope) {
|
||
if env.OperationID == "" {
|
||
return
|
||
}
|
||
q.records[operationKey(owner, projectID, env.OperationID)] = OperationRecord{
|
||
OperationID: env.OperationID, CommandID: env.CommandID, State: env.State, Time: env.Time,
|
||
}
|
||
}
|
||
|
||
// TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID 是 F10 的核心回归:
|
||
// handler 已经写库、之后才失败时,回执必须是 unknown 且携带它拿到的
|
||
// operationID,理由点明"执行开始后失败",绝不退化成 rejected(会诱发不安全重试)。
|
||
func TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID(t *testing.T) {
|
||
audit := &memAudit{}
|
||
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||
var writes []string
|
||
var seen string
|
||
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.write", Title: "写入"}, "", func(_ context.Context, req Request) (any, error) {
|
||
seen = req.OperationID
|
||
writes = append(writes, req.OperationID) // 副作用已发生
|
||
return nil, errors.New("commit acknowledgement lost")
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resp := bus.Dispatch(context.Background(), Request{ID: "demo.write", Actor: "alice", Owner: "alice"})
|
||
if resp.Receipt.State != agentcommand.StateUnknown {
|
||
t.Fatalf("post-execution failure must not be reported as rejected: %+v", resp.Receipt)
|
||
}
|
||
if seen == "" || resp.Receipt.OperationID != seen {
|
||
t.Fatalf("unknown receipt lost the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen)
|
||
}
|
||
if !strings.Contains(resp.Receipt.Reason, "after execution began") {
|
||
t.Fatalf("reason must name post-execution uncertainty: %q", resp.Receipt.Reason)
|
||
}
|
||
if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible {
|
||
t.Fatalf("side effects = %q, want %q", resp.Receipt.SideEffects, agentcommand.SideEffectsPossible)
|
||
}
|
||
if err := resp.Receipt.Validate(); err != nil {
|
||
t.Fatalf("trusted unknown receipt must satisfy the library contract: %v", err)
|
||
}
|
||
if len(writes) != 1 {
|
||
t.Fatalf("handler ran %d times, want 1", len(writes))
|
||
}
|
||
// 对账凭证:审计信封落 unknown + 同一个 operationID。
|
||
if len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateUnknown || audit.logs[0].OperationID != seen {
|
||
t.Fatalf("audit envelope is not reconcilable: %+v", audit.logs)
|
||
}
|
||
// 宿主不得自动重放:再次分发是一次新操作(新的 operationID),旧的不被复用。
|
||
again := bus.Dispatch(context.Background(), Request{ID: "demo.write", Actor: "alice", Owner: "alice"})
|
||
if again.Receipt.State != agentcommand.StateUnknown || again.Receipt.OperationID == resp.Receipt.OperationID || len(writes) != 2 {
|
||
t.Fatalf("re-dispatch must be a new operation, not a replay: %+v writes=%d", again.Receipt, len(writes))
|
||
}
|
||
}
|
||
|
||
// TestHandlerNotExecutedErrorStaysRejected:handler 显式声明未执行才是
|
||
// rejected 的依据;回执不得携带 operationID(库契约)。
|
||
func TestHandlerNotExecutedErrorStaysRejected(t *testing.T) {
|
||
audit := &memAudit{}
|
||
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||
calls := 0
|
||
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.guard", Title: "守卫"}, "", func(context.Context, Request) (any, error) {
|
||
calls++
|
||
boom := errors.New("permission denied")
|
||
return nil, NotExecuted(boom)
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resp := bus.Dispatch(context.Background(), Request{ID: "demo.guard", Owner: "alice", Actor: "alice"})
|
||
if resp.Receipt.State != agentcommand.StateRejected || resp.Receipt.OperationID != "" {
|
||
t.Fatalf("not-executed failure must stay rejected without an ID: %+v", resp.Receipt)
|
||
}
|
||
if resp.Receipt.Reason != "rejected: permission denied" {
|
||
t.Fatalf("not-executed marking must not distort the reason: %q", resp.Receipt.Reason)
|
||
}
|
||
if resp.Receipt.SideEffects != agentcommand.SideEffectsNone || resp.Receipt.Validate() != nil {
|
||
t.Fatalf("rejected receipt must declare no side effects and validate: %+v", resp.Receipt)
|
||
}
|
||
if calls != 1 || len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateRejected || audit.logs[0].OperationID != "" {
|
||
t.Fatalf("unexpected effects: calls=%d audits=%+v", calls, audit.logs)
|
||
}
|
||
// 标记语义:errors.Is 同时命中 ErrNotExecuted 与原始错误;nil 幂等。
|
||
wrapped := NotExecuted(errors.New("boom"))
|
||
if !IsNotExecuted(wrapped) || !errors.Is(wrapped, ErrNotExecuted) || wrapped.Error() != "boom" {
|
||
t.Fatalf("not-executed wrapper semantics: %v / %v", wrapped, IsNotExecuted(wrapped))
|
||
}
|
||
if NotExecuted(nil) != nil || !IsNotExecuted(NotExecuted(wrapped)) {
|
||
t.Fatal("not-executed marking must be nil-safe and idempotent")
|
||
}
|
||
}
|
||
|
||
// TestCommittedWithAuditFailureKeepsOperationID 是 F10 Bug 2 的回归:审计
|
||
// 落盘失败时回执仍是 unknown,但必须带着已交给 handler 的 operationID,
|
||
// 理由点名审计不可用,调用方才能按记录对账而不是重发。
|
||
func TestCommittedWithAuditFailureKeepsOperationID(t *testing.T) {
|
||
audit := &memAudit{fail: true}
|
||
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||
var seen string
|
||
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.ping", Title: "Ping"}, "", func(_ context.Context, req Request) (any, error) {
|
||
seen = req.OperationID
|
||
return map[string]any{"pong": true}, nil
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resp := bus.Dispatch(context.Background(), Request{ID: "demo.ping", Owner: "alice", Actor: "alice"})
|
||
if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "audit unavailable") {
|
||
t.Fatalf("audit loss must stay unknown and name the audit: %+v", resp.Receipt)
|
||
}
|
||
if seen == "" || resp.Receipt.OperationID != seen {
|
||
t.Fatalf("audit loss dropped the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen)
|
||
}
|
||
if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible || resp.Receipt.Validate() != nil {
|
||
t.Fatalf("audit-loss receipt must declare possible side effects and validate: %+v", resp.Receipt)
|
||
}
|
||
if len(audit.logs) != 0 {
|
||
t.Fatalf("audit rows written while failing: %+v", audit.logs)
|
||
}
|
||
// 校验:未执行路径(rejected)在审计不可用时不得凭空出现 operationID。
|
||
rejected := bus.Dispatch(context.Background(), Request{ID: "demo.absent"})
|
||
if rejected.Receipt.OperationID != "" || rejected.Receipt.State != agentcommand.StateUnknown {
|
||
t.Fatalf("audit loss must not invent an operation ID: %+v", rejected.Receipt)
|
||
}
|
||
}
|
||
|
||
// TestValidationRejectionNeverRunsHandler:显式校验拒绝仍是 rejected、无 ID、
|
||
// 无副作用,且 handler 一次都不许跑。
|
||
func TestValidationRejectionNeverRunsHandler(t *testing.T) {
|
||
audit := &memAudit{}
|
||
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||
calls := 0
|
||
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{
|
||
ID: "demo.needs", Title: "需要参数",
|
||
ParamsSchema: json.RawMessage(`{"type":"object","required":["target"],"properties":{"target":{"type":"string"}}}`),
|
||
}, "", func(context.Context, Request) (any, error) {
|
||
calls++
|
||
return "ran", nil
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resp := bus.Dispatch(context.Background(), Request{ID: "demo.needs", Owner: "alice", Params: map[string]any{}})
|
||
if resp.Receipt.State != agentcommand.StateRejected || resp.Receipt.OperationID != "" || resp.Receipt.Validate() != nil {
|
||
t.Fatalf("validation rejection: %+v", resp.Receipt)
|
||
}
|
||
if resp.Receipt.SideEffects != agentcommand.SideEffectsNone || calls != 0 {
|
||
t.Fatalf("validation rejection ran the handler or claimed effects: calls=%d %+v", calls, resp.Receipt)
|
||
}
|
||
if len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateRejected || audit.logs[0].OperationID != "" {
|
||
t.Fatalf("validation audit row: %+v", audit.logs)
|
||
}
|
||
}
|
||
|
||
// TestRepeatedConfirmExecutesOnce:确认卡重复确认不得二次执行(F10 分类改动
|
||
// 不得放宽既有收口语义);unknown 卡面也不得悬在待确认。
|
||
func TestRepeatedConfirmExecutesOnce(t *testing.T) {
|
||
audit, cards := &memAudit{}, &memCards{}
|
||
bus := New(audit, cards, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||
executed := 0
|
||
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{
|
||
ID: "demo.once", Title: "只执行一次", Danger: agentcommand.DangerHigh,
|
||
}, "/once", func(context.Context, Request) (any, error) {
|
||
executed++
|
||
return "done", nil
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if r := bus.Dispatch(context.Background(), Request{ID: "demo.once", Owner: "alice", ConversationID: "cnv_1"}); r.Receipt.State != agentcommand.StateAccepted {
|
||
t.Fatalf("pre-confirm: %+v", r.Receipt)
|
||
}
|
||
first := bus.Confirm("alice", "card_x1", "confirm")
|
||
second := bus.Confirm("alice", "card_x1", "confirm")
|
||
if first.Receipt.State != agentcommand.StateCommitted || executed != 1 {
|
||
t.Fatalf("confirm: %+v executed=%d", first.Receipt, executed)
|
||
}
|
||
if second.Receipt.State != agentcommand.StateRejected || second.Receipt.OperationID != "" || executed != 1 {
|
||
t.Fatalf("re-confirm executed twice: %+v executed=%d", second.Receipt, executed)
|
||
}
|
||
}
|
||
|
||
// TestLookupOperationIsOwnerProjectScoped(R7/KTD6):unknown 回执的对账查询
|
||
// 复用 owner/project 等值作用域;跨 owner、跨 project、缺作用域、未装配端口
|
||
// 一律 fail-closed,查不到绝不推断"未执行"。
|
||
func TestLookupOperationIsOwnerProjectScoped(t *testing.T) {
|
||
audit := &memAudit{}
|
||
query := &memOperationQuery{records: map[string]OperationRecord{}}
|
||
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||
if _, err := bus.LookupOperation("alice", "proj_1", "op_1"); !errors.Is(err, ErrOperationQueryUnavailable) {
|
||
t.Fatalf("unwired query must fail closed: %v", err)
|
||
}
|
||
bus.SetOperationQuery(query)
|
||
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.write", Title: "写入"}, "", func(context.Context, Request) (any, error) {
|
||
// 副作用之后失败 → unknown + operationID(对账句柄)。
|
||
return nil, errors.New("ack lost")
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resp := bus.Dispatch(context.Background(), Request{ID: "demo.write", Owner: "alice", Actor: "alice"})
|
||
if resp.Receipt.State != agentcommand.StateUnknown || resp.Receipt.OperationID == "" {
|
||
t.Fatalf("setup dispatch: %+v", resp.Receipt)
|
||
}
|
||
// 对账记录=总线真实落下的审计信封(同一 owner/project 快照)。
|
||
for _, env := range audit.logs {
|
||
query.recordAudit("alice", "proj_1", env)
|
||
}
|
||
op := resp.Receipt.OperationID
|
||
record, err := bus.LookupOperation("alice", "proj_1", op)
|
||
if err != nil {
|
||
t.Fatalf("owner-scoped lookup: %v", err)
|
||
}
|
||
if record.OperationID != op || record.CommandID != "demo.write" || record.State != agentcommand.StateUnknown {
|
||
t.Fatalf("lookup returned the wrong record: %+v", record)
|
||
}
|
||
if _, err := bus.LookupOperation("bob", "proj_1", op); !errors.Is(err, ErrOperationNotRecorded) {
|
||
t.Fatalf("cross-owner lookup must not resolve: %v", err)
|
||
}
|
||
if _, err := bus.LookupOperation("alice", "proj_other", op); !errors.Is(err, ErrOperationNotRecorded) {
|
||
t.Fatalf("cross-project lookup must not resolve: %v", err)
|
||
}
|
||
for _, scope := range [][3]string{{"", "proj_1", op}, {"alice", "", op}, {"alice", "proj_1", ""}} {
|
||
if _, err := bus.LookupOperation(scope[0], scope[1], scope[2]); !errors.Is(err, ErrOperationScope) {
|
||
t.Fatalf("scope %v must fail closed: %v", scope, err)
|
||
}
|
||
}
|
||
if _, err := bus.LookupOperation("bob", "proj_1", "op_absent"); !errors.Is(err, ErrOperationNotRecorded) {
|
||
t.Fatalf("unknown operation must not be reported as executed: %v", err)
|
||
}
|
||
}
|