approval_test.go
1 package daemon 2 3 import ( 4 "context" 5 "fmt" 6 "os" 7 "path/filepath" 8 "sync" 9 "testing" 10 "time" 11 12 "github.com/Kocoro-lab/ShanClaw/internal/config" 13 "github.com/Kocoro-lab/ShanClaw/internal/permissions" 14 ) 15 16 func TestApprovalBroker_RequestResolve(t *testing.T) { 17 var sent []ApprovalRequest 18 var mu sync.Mutex 19 sendFn := func(req ApprovalRequest) error { 20 mu.Lock() 21 sent = append(sent, req) 22 mu.Unlock() 23 return nil 24 } 25 26 broker := NewApprovalBroker(sendFn) 27 28 // Resolve in a goroutine after a short delay 29 go func() { 30 time.Sleep(50 * time.Millisecond) 31 mu.Lock() 32 reqID := sent[0].RequestID 33 mu.Unlock() 34 broker.Resolve(reqID, DecisionAllow) 35 }() 36 37 decision := broker.Request(context.Background(), "ch1", "th1", "bot", "bash", `{"command":"ls"}`) 38 if decision != DecisionAllow { 39 t.Errorf("expected allow, got %s", decision) 40 } 41 if len(sent) != 1 { 42 t.Fatalf("expected 1 sent request, got %d", len(sent)) 43 } 44 if sent[0].Tool != "bash" { 45 t.Errorf("expected tool=bash, got %s", sent[0].Tool) 46 } 47 } 48 49 func TestApprovalBroker_ContextCancel(t *testing.T) { 50 broker := NewApprovalBroker(func(req ApprovalRequest) error { return nil }) 51 52 ctx, cancel := context.WithCancel(context.Background()) 53 go func() { 54 time.Sleep(50 * time.Millisecond) 55 cancel() 56 }() 57 58 decision := broker.Request(ctx, "ch1", "th1", "bot", "bash", `{}`) 59 if decision != DecisionDeny { 60 t.Errorf("expected deny on ctx cancel, got %s", decision) 61 } 62 } 63 64 func TestApprovalBroker_CancelAll(t *testing.T) { 65 broker := NewApprovalBroker(func(req ApprovalRequest) error { return nil }) 66 67 results := make(chan ApprovalDecision, 3) 68 for i := 0; i < 3; i++ { 69 go func() { 70 results <- broker.Request(context.Background(), "ch1", "th1", "bot", "bash", `{}`) 71 }() 72 } 73 74 // Let requests register 75 time.Sleep(50 * time.Millisecond) 76 77 broker.CancelAll() 78 79 for i := 0; i < 3; i++ { 80 select { 81 case d := <-results: 82 if d != DecisionDeny { 83 t.Errorf("expected deny from CancelAll, got %s", d) 84 } 85 case <-time.After(time.Second): 86 t.Fatal("CancelAll did not unblock all pending requests") 87 } 88 } 89 } 90 91 func TestApprovalBroker_SendFails(t *testing.T) { 92 broker := NewApprovalBroker(func(req ApprovalRequest) error { 93 return fmt.Errorf("not connected") 94 }) 95 96 decision := broker.Request(context.Background(), "ch1", "th1", "bot", "bash", `{}`) 97 if decision != DecisionDeny { 98 t.Errorf("expected deny on send failure, got %s", decision) 99 } 100 } 101 102 func TestApprovalBroker_ResolveUnknown(t *testing.T) { 103 broker := NewApprovalBroker(func(req ApprovalRequest) error { return nil }) 104 // Should not panic 105 broker.Resolve("nonexistent", DecisionAllow) 106 } 107 108 func TestApprovalBroker_ConcurrentRequests(t *testing.T) { 109 var mu sync.Mutex 110 var sent []ApprovalRequest 111 broker := NewApprovalBroker(func(req ApprovalRequest) error { 112 mu.Lock() 113 sent = append(sent, req) 114 mu.Unlock() 115 return nil 116 }) 117 118 const n = 5 119 results := make(chan ApprovalDecision, n) 120 121 for i := 0; i < n; i++ { 122 go func() { 123 results <- broker.Request(context.Background(), "ch1", "th1", "bot", "bash", `{}`) 124 }() 125 } 126 127 // Let all requests register 128 time.Sleep(100 * time.Millisecond) 129 130 mu.Lock() 131 for _, req := range sent { 132 broker.Resolve(req.RequestID, DecisionAllow) 133 } 134 mu.Unlock() 135 136 for i := 0; i < n; i++ { 137 select { 138 case d := <-results: 139 if d != DecisionAllow { 140 t.Errorf("expected allow, got %s", d) 141 } 142 case <-time.After(time.Second): 143 t.Fatal("not all concurrent requests resolved") 144 } 145 } 146 } 147 148 func TestAlwaysAllowBashPersistence(t *testing.T) { 149 dir := t.TempDir() 150 os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("endpoint: test\n"), 0644) 151 152 // Persist a bash command 153 err := config.AppendAllowedCommand(dir, "git status") 154 if err != nil { 155 t.Fatalf("persist failed: %v", err) 156 } 157 158 // Verify it matches via permission engine 159 cfg := &permissions.PermissionsConfig{ 160 AllowedCommands: []string{"git status"}, 161 } 162 decision, _ := permissions.CheckCommand("git status", cfg) 163 if decision != "allow" { 164 t.Errorf("expected allow, got %s", decision) 165 } 166 167 // Different command should not match 168 decision, _ = permissions.CheckCommand("git push", cfg) 169 if decision == "allow" { 170 t.Error("git push should not be auto-allowed by git status pattern") 171 } 172 } 173 174 func TestApprovalBroker_ToolAutoApprove(t *testing.T) { 175 broker := NewApprovalBroker(func(req ApprovalRequest) error { return nil }) 176 broker.SetToolAutoApprove("file_write") 177 178 if !broker.IsToolAutoApproved("file_write") { 179 t.Error("file_write should be auto-approved") 180 } 181 if broker.IsToolAutoApproved("bash") { 182 t.Error("bash should not be auto-approved") 183 } 184 }