Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes. Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending. Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete. Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
170 lines
6.2 KiB
Go
170 lines
6.2 KiB
Go
package cmdbus
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
agentcommand "agent-command"
|
|
)
|
|
|
|
func TestEffectiveAccountGuardRejectsBeforeDispatchEffects(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
req Request
|
|
err error
|
|
want string
|
|
}{
|
|
{"disabled handler", Request{ID: "demo.ping", Owner: "alice", Actor: "alice"}, errors.New("disabled"), "alice"},
|
|
{"failed lookup", Request{ID: "demo.ping", Owner: "alice", Actor: "alice"}, errors.New("database unavailable"), "alice"},
|
|
{"plugin actor uses account owner", Request{ID: "demo.ping", Owner: "alice", Actor: "plugin:rdm"}, errors.New("disabled"), "alice"},
|
|
{"actor fallback", Request{ID: "demo.ping", Actor: "alice"}, errors.New("disabled"), "alice"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
audit, cards := &memAudit{}, &memCards{}
|
|
b := newTestBus(audit, cards)
|
|
called := 0
|
|
b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error {
|
|
called++
|
|
if actor != tc.want {
|
|
t.Fatalf("guard actor = %q, want %q", actor, tc.want)
|
|
}
|
|
return tc.err
|
|
})
|
|
resp := b.Dispatch(context.Background(), tc.req)
|
|
if resp.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp.Receipt.Reason, "effective account") {
|
|
t.Fatalf("guard rejection: %+v", resp.Receipt)
|
|
}
|
|
if called != 1 || len(audit.logs) != 0 || len(cards.created) != 0 {
|
|
t.Fatalf("guard calls=%d audits=%d cards=%d", called, len(audit.logs), len(cards.created))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEffectiveAccountGuardRejectsBeforePendingConfirmAndRechecks(t *testing.T) {
|
|
audit, cards := &memAudit{}, &memCards{}
|
|
b := newTestBus(audit, cards)
|
|
allowed := false
|
|
b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error {
|
|
if actor != "alice" {
|
|
t.Fatalf("guard actor = %q", actor)
|
|
}
|
|
if !allowed {
|
|
return errors.New("disabled")
|
|
}
|
|
return nil
|
|
})
|
|
req := Request{ID: "demo.risky", Owner: "alice", Actor: "alice", ConversationID: "cnv_1", Params: map[string]any{"target": "prod"}}
|
|
if r := b.Dispatch(context.Background(), req); r.Receipt.State != agentcommand.StateRejected {
|
|
t.Fatalf("disabled dispatch: %+v", r.Receipt)
|
|
}
|
|
if len(cards.created) != 0 || len(b.pending) != 0 || len(audit.logs) != 0 {
|
|
t.Fatal("disabled dispatch created confirmation effects")
|
|
}
|
|
allowed = true
|
|
r := b.Dispatch(context.Background(), req)
|
|
if r.Receipt.State != agentcommand.StateAccepted || len(cards.created) != 1 || len(b.pending) != 1 {
|
|
t.Fatalf("enabled dispatch: %+v", r.Receipt)
|
|
}
|
|
allowed = false
|
|
before := len(audit.logs)
|
|
if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateRejected {
|
|
t.Fatalf("disabled confirm: %+v", r.Receipt)
|
|
}
|
|
if len(b.pending) != 1 || len(audit.logs) != before {
|
|
t.Fatal("disabled confirm consumed pending request or wrote audit")
|
|
}
|
|
allowed = true
|
|
if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateCommitted {
|
|
t.Fatalf("re-enabled confirm: %+v", r.Receipt)
|
|
}
|
|
}
|
|
|
|
func TestEffectiveAccountGuardStopsConfirmedHandlerAfterStateChanges(t *testing.T) {
|
|
b := New(&memAudit{}, &memCards{}, nil)
|
|
executed := 0
|
|
if err := b.RegisterPluginCommand(agentcommand.CommandSpec{ID: "demo.effect", Title: "Effect"}, func(context.Context, Request) (any, error) {
|
|
executed++
|
|
return "done", nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
checks := 0
|
|
b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error {
|
|
checks++
|
|
if actor != "alice" {
|
|
t.Fatalf("guard actor = %q", actor)
|
|
}
|
|
if checks == 4 {
|
|
return errors.New("disabled after confirmation started")
|
|
}
|
|
return nil
|
|
})
|
|
// Confirm passes its own check, then Dispatch must check once more before
|
|
// invoking the handler. A direct confirmed request takes the same path.
|
|
if err := b.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.high", Title: "High", Danger: agentcommand.DangerHigh}, "", func(context.Context, Request) (any, error) {
|
|
executed++
|
|
return "done", nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := b.Dispatch(context.Background(), Request{ID: "demo.high", Owner: "alice", ConversationID: "cnv_1"})
|
|
if r.Receipt.State != agentcommand.StateAccepted {
|
|
t.Fatalf("initial dispatch: %+v", r.Receipt)
|
|
}
|
|
// Dispatch checks again before creating the confirmation card. Confirm's
|
|
// check passes; its nested Dispatch catches the fourth-state change.
|
|
if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateRejected {
|
|
t.Fatalf("confirm after disable: %+v", r.Receipt)
|
|
}
|
|
if executed != 0 || len(b.pending) != 0 {
|
|
t.Fatalf("executed=%d pending=%d", executed, len(b.pending))
|
|
}
|
|
// Direct confirmed execution also passes through the same guard.
|
|
checks = 3
|
|
if r := b.Dispatch(context.Background(), Request{ID: "demo.effect", Owner: "alice", Confirmed: true}); r.Receipt.State != agentcommand.StateRejected || executed != 0 {
|
|
t.Fatalf("direct confirmed dispatch: %+v executed=%d", r.Receipt, executed)
|
|
}
|
|
}
|
|
|
|
type trackingPendingStore struct {
|
|
owner, payload string
|
|
takes int
|
|
}
|
|
|
|
func (p *trackingPendingStore) SavePendingConfirm(_ string, owner, payload string) error {
|
|
p.owner, p.payload = owner, payload
|
|
return nil
|
|
}
|
|
func (p *trackingPendingStore) TakePendingConfirm(string) (string, string, bool, error) {
|
|
p.takes++
|
|
return p.owner, p.payload, p.payload != "", nil
|
|
}
|
|
|
|
func TestEffectiveAccountGuardDoesNotConsumeStoredPendingOnDeniedConfirm(t *testing.T) {
|
|
b := newTestBus(&memAudit{}, &memCards{})
|
|
pendingStorage := &trackingPendingStore{}
|
|
b.SetPendingStore(pendingStorage)
|
|
allowed := true
|
|
b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error {
|
|
if actor != "alice" {
|
|
t.Fatalf("guard actor = %q", actor)
|
|
}
|
|
if !allowed {
|
|
return errors.New("database unavailable")
|
|
}
|
|
return nil
|
|
})
|
|
r := b.Dispatch(context.Background(), Request{ID: "demo.risky", Owner: "alice", ConversationID: "cnv_1", Params: map[string]any{"target": "prod"}})
|
|
if r.Receipt.State != agentcommand.StateAccepted || pendingStorage.payload == "" {
|
|
t.Fatalf("pending setup: %+v", r.Receipt)
|
|
}
|
|
b.pending = map[string]pending{} // force the restored path
|
|
allowed = false
|
|
if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateRejected || pendingStorage.takes != 0 {
|
|
t.Fatalf("denied confirm: %+v takes=%d", r.Receipt, pendingStorage.takes)
|
|
}
|
|
}
|