/ internal / daemon / approval_test.go
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  }