package approval import ( "context" "encoding/json" "errors" "strings" "testing" "github.com/johannSo/codey/internal/hooks" ) // recordHooks attaches a recorder and returns the payloads it will fill. func recordHooks(p *Policy) *[]hooks.Payload { var got []hooks.Payload p.Hooks = hooks.New("s1") p.Hooks.Add(hooks.HandlerFunc(func(_ context.Context, pl hooks.Payload) error { got = append(got, pl) return nil })) return &got } func TestPromptIsBracketedByBlockedAndUnblocked(t *testing.T) { tests := []struct { name string decision Decision promptErr error }{ {name: "allowed once", decision: Once}, {name: "denied", decision: Deny}, {name: "prompt failed", promptErr: errors.New("turn cancelled")}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // The prompt writes into the same sequence as the hooks, so the // test can tell "Blocked before the human was asked" from // "Blocked at some point". var order []string p := New(func(context.Context, Request) (Decision, error) { order = append(order, "prompt") return tc.decision, tc.promptErr }) var payloads []hooks.Payload p.Hooks = hooks.New("s1") p.Hooks.Add(hooks.HandlerFunc(func(_ context.Context, pl hooks.Payload) error { order = append(order, string(pl.Event)) payloads = append(payloads, pl) return nil })) err := p.ApproveAs(context.Background(), "a3", "bash", json.RawMessage(`{"command":"go test ./..."}`)) if tc.promptErr != nil && !errors.Is(err, tc.promptErr) { t.Fatalf("ApproveAs = %v, want the prompt's own error", err) } if want := "blocked prompt unblocked"; strings.Join(order, " ") != want { t.Fatalf("order = %v, want %q", order, want) } for _, pl := range payloads { if pl.Reason != hooks.ReasonApproval { t.Errorf("%s.Reason = %q, want %q", pl.Event, pl.Reason, hooks.ReasonApproval) } if pl.Tool != "bash" { t.Errorf("%s.Tool = %q, want bash", pl.Event, pl.Tool) } if pl.Agent != "a3" { t.Errorf("%s.Agent = %q, want the asking subagent", pl.Event, pl.Agent) } } // The command is what the prompt itself shows, and what lets a // decision be made from a phone. if msg := payloads[0].Message; !strings.Contains(msg, "go test ./...") { t.Errorf("Blocked.Message = %q, want the command in it", msg) } }) } } // Blocked means a human is being waited on. A call nobody was asked about // must not say so, or a sidebar flashes "blocked" on every read. func TestNothingFiresWhenNoPromptIsNeeded(t *testing.T) { tests := []struct { name string setup func(p *Policy) tool string input string }{ {name: "read-only tool", tool: "read_file", input: `{"path":"a.go"}`}, {name: "YOLO", setup: func(p *Policy) { p.YOLO = true }, tool: "bash", input: `{"command":"rm -rf build"}`}, { name: "tool remembered as always allowed", setup: func(p *Policy) { // The remembering itself goes through the prompt, so its // events are drained before the call under test. approve(t, p, "bash", `{"command":"ls"}`) }, tool: "bash", input: `{"command":"go build ./..."}`, }, { name: "command remembered as always allowed", setup: func(p *Policy) { p.Prompt = func(context.Context, Request) (Decision, error) { return AlwaysThisCommand, nil } approve(t, p, "bash", `{"command":"go test ./..."}`) }, tool: "bash", input: `{"command":"go test ./..."}`, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { p := New(func(context.Context, Request) (Decision, error) { return AlwaysThisTool, nil }) got := recordHooks(p) if tc.setup != nil { tc.setup(p) } *got = (*got)[:0] if err := approve(t, p, tc.tool, tc.input); err != nil { t.Fatalf("%s was denied: %v", tc.tool, err) } if len(*got) != 0 { t.Errorf("fired %+v for a call nobody was asked about", *got) } }) } } // A headless denial is instant, so there is no wait to announce. func TestNilPromptFiresNothing(t *testing.T) { p := New(nil) got := recordHooks(p) if err := approve(t, p, "bash", `{"command":"ls"}`); err == nil { t.Fatal("a mutating call was allowed with no way to ask") } if len(*got) != 0 { t.Errorf("fired %+v with no prompt installed", *got) } }