Files
gnoma/internal/hook/command_test.go

236 lines
6.6 KiB
Go

package hook
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
)
func TestCommandExecutor_ExitZero_Allow(t *testing.T) {
def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeShell, Exec: "exit 0"}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), []byte(`{}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Action != Allow {
t.Errorf("action = %v, want Allow", result.Action)
}
}
func TestCommandExecutor_ExitOne_Skip(t *testing.T) {
def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeShell, Exec: "exit 1"}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), []byte(`{}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Action != Skip {
t.Errorf("action = %v, want Skip", result.Action)
}
}
func TestCommandExecutor_ExitTwo_Deny(t *testing.T) {
def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeShell, Exec: "exit 2"}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), []byte(`{}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Action != Deny {
t.Errorf("action = %v, want Deny", result.Action)
}
}
func TestCommandExecutor_StdinDelivered(t *testing.T) {
// Hook reads stdin and echoes it back to stdout as JSON with action=allow
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
Exec: `read -r line; echo '{"action":"allow","transformed":'"$line"'}'; exit 0`,
}
ex := NewCommandExecutor(def)
payload := []byte(`{"tool":"bash"}`)
result, err := ex.Execute(context.Background(), payload)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Action != Allow {
t.Errorf("action = %v, want Allow", result.Action)
}
}
func TestCommandExecutor_StdoutJSON_Parsed(t *testing.T) {
// Hook outputs JSON with transformed payload
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
Exec: `echo '{"action":"deny","transformed":{"command":"safe"}}'`,
}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), []byte(`{}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Action != Deny {
t.Errorf("action = %v, want Deny", result.Action)
}
if result.Output == nil {
t.Error("expected non-nil transformed output")
}
}
func TestCommandExecutor_StdoutJSON_ActionOverridesExitCode(t *testing.T) {
// exit 0, but stdout says deny
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
Exec: `echo '{"action":"deny"}'; exit 0`,
}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), []byte(`{}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Action != Deny {
t.Errorf("action = %v, want Deny", result.Action)
}
}
func TestCommandExecutor_EmptyStdout_ExitCodeFallback(t *testing.T) {
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
Exec: `exit 2`,
}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), []byte(`{}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Action != Deny {
t.Errorf("action = %v, want Deny", result.Action)
}
if result.Output != nil {
t.Error("expected nil output for empty stdout")
}
}
func TestCommandExecutor_Timeout_FailOpenTrue(t *testing.T) {
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
Exec: "sleep 10",
Timeout: 50 * time.Millisecond,
FailOpen: true,
}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), []byte(`{}`))
if err == nil {
t.Fatal("expected error on timeout")
}
if result.Action != Allow {
t.Errorf("fail_open=true: action = %v, want Allow", result.Action)
}
}
func TestCommandExecutor_Timeout_FailOpenFalse(t *testing.T) {
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
Exec: "sleep 10",
Timeout: 50 * time.Millisecond,
FailOpen: false,
}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), []byte(`{}`))
if err == nil {
t.Fatal("expected error on timeout")
}
if result.Action != Deny {
t.Errorf("fail_open=false: action = %v, want Deny", result.Action)
}
}
func TestCommandExecutor_InvalidJSON_Stdout(t *testing.T) {
// Hook writes garbage — should error
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
Exec: `echo "not valid json"`,
}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), []byte(`{}`))
if err == nil {
t.Fatal("expected error for invalid JSON stdout")
}
// fail_open=false (default) → Deny on error
if result.Action != Deny {
t.Errorf("action = %v, want Deny on error with fail_open=false", result.Action)
}
}
func TestCommandExecutor_Duration_Recorded(t *testing.T) {
def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeShell, Exec: "exit 0"}
ex := NewCommandExecutor(def)
result, _ := ex.Execute(context.Background(), []byte(`{}`))
if result.Duration <= 0 {
t.Error("expected Duration > 0")
}
}
func TestCommandExecutor_PreToolPayload_HasToolField(t *testing.T) {
// Verify that a real pre_tool_use payload round-trips through the executor
args := json.RawMessage(`{"command":"ls"}`)
payload := MarshalPreToolPayload("bash", args)
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
// Read tool name from stdin, allow if it's "bash"
Exec: `input=$(cat); tool=$(echo "$input" | grep -o '"tool":"[^"]*"' | cut -d'"' -f4); [ "$tool" = "bash" ] && exit 0 || exit 2`,
}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), payload)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Action != Allow {
t.Errorf("action = %v, want Allow for tool=bash", result.Action)
}
}
// Verify the executor honours context cancellation in addition to its own timeout.
func TestCommandExecutor_ContextCancelled(t *testing.T) {
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
Exec: "sleep 10",
FailOpen: true,
}
ex := NewCommandExecutor(def)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
result, err := ex.Execute(ctx, []byte(`{}`))
if err == nil {
t.Fatal("expected error on context cancellation")
}
if !strings.Contains(err.Error(), "hook") {
t.Logf("error = %v", err) // just informational
}
if result.Action != Allow { // fail_open=true
t.Errorf("action = %v, want Allow (fail_open=true)", result.Action)
}
}