permission_test.go

  1package permission
  2
  3import (
  4	"sync"
  5	"testing"
  6
  7	"github.com/charmbracelet/crush/internal/pubsub"
  8	"github.com/stretchr/testify/assert"
  9	"github.com/stretchr/testify/require"
 10)
 11
 12func TestPermissionService_AllowedCommands(t *testing.T) {
 13	tests := []struct {
 14		name         string
 15		allowedTools []string
 16		toolName     string
 17		action       string
 18		expected     bool
 19	}{
 20		{
 21			name:         "tool in allowlist",
 22			allowedTools: []string{"bash", "view"},
 23			toolName:     "bash",
 24			action:       "execute",
 25			expected:     true,
 26		},
 27		{
 28			name:         "tool:action in allowlist",
 29			allowedTools: []string{"bash:execute", "edit:create"},
 30			toolName:     "bash",
 31			action:       "execute",
 32			expected:     true,
 33		},
 34		{
 35			name:         "tool not in allowlist",
 36			allowedTools: []string{"view", "ls"},
 37			toolName:     "bash",
 38			action:       "execute",
 39			expected:     false,
 40		},
 41		{
 42			name:         "tool:action not in allowlist",
 43			allowedTools: []string{"bash:read", "edit:create"},
 44			toolName:     "bash",
 45			action:       "execute",
 46			expected:     false,
 47		},
 48		{
 49			name:         "empty allowlist",
 50			allowedTools: []string{},
 51			toolName:     "bash",
 52			action:       "execute",
 53			expected:     false,
 54		},
 55	}
 56
 57	for _, tt := range tests {
 58		t.Run(tt.name, func(t *testing.T) {
 59			service := NewPermissionService("/tmp", false, tt.allowedTools)
 60
 61			// Create a channel to capture the permission request
 62			// Since we're testing the allowlist logic, we need to simulate the request
 63			ps := service.(*permissionService)
 64
 65			// Test the allowlist logic directly
 66			commandKey := tt.toolName + ":" + tt.action
 67			allowed := false
 68			for _, cmd := range ps.allowedTools {
 69				if cmd == commandKey || cmd == tt.toolName {
 70					allowed = true
 71					break
 72				}
 73			}
 74
 75			if allowed != tt.expected {
 76				t.Errorf("expected %v, got %v for tool %s action %s with allowlist %v",
 77					tt.expected, allowed, tt.toolName, tt.action, tt.allowedTools)
 78			}
 79		})
 80	}
 81}
 82
 83func TestPermissionService_SkipMode(t *testing.T) {
 84	service := NewPermissionService("/tmp", true, []string{})
 85
 86	result, err := service.Request(t.Context(), CreatePermissionRequest{
 87		SessionID:   "test-session",
 88		ToolName:    "bash",
 89		Action:      "execute",
 90		Description: "test command",
 91		Path:        "/tmp",
 92	})
 93	if err != nil {
 94		t.Errorf("unexpected error: %v", err)
 95	}
 96	if !result {
 97		t.Error("expected permission to be granted in skip mode")
 98	}
 99}
100
101func TestPermissionService_SequentialProperties(t *testing.T) {
102	t.Run("Sequential permission requests with persistent grants", func(t *testing.T) {
103		service := NewPermissionService("/tmp", false, []string{})
104
105		req1 := CreatePermissionRequest{
106			SessionID:   "session1",
107			ToolName:    "file_tool",
108			Description: "Read file",
109			Action:      "read",
110			Params:      map[string]string{"file": "test.txt"},
111			Path:        "/tmp/test.txt",
112		}
113
114		var result1 bool
115		var wg sync.WaitGroup
116		wg.Add(1)
117
118		events := make(chan pubsub.Event[PermissionRequest], 10)
119		service.AddListener(func(event pubsub.Event[PermissionRequest]) {
120			events <- event
121		})
122
123		go func() {
124			defer wg.Done()
125			result1, _ = service.Request(t.Context(), req1)
126		}()
127
128		var permissionReq PermissionRequest
129		event := <-events
130
131		permissionReq = event.Payload
132		service.GrantPersistent(t.Context(), permissionReq)
133
134		wg.Wait()
135		assert.True(t, result1, "First request should be granted")
136
137		// Second identical request should be automatically approved due to persistent permission
138		req2 := CreatePermissionRequest{
139			SessionID:   "session1",
140			ToolName:    "file_tool",
141			Description: "Read file again",
142			Action:      "read",
143			Params:      map[string]string{"file": "test.txt"},
144			Path:        "/tmp/test.txt",
145		}
146		result2, err := service.Request(t.Context(), req2)
147		require.NoError(t, err)
148		assert.True(t, result2, "Second request should be auto-approved")
149	})
150	t.Run("Sequential requests with temporary grants", func(t *testing.T) {
151		service := NewPermissionService("/tmp", false, []string{})
152
153		req := CreatePermissionRequest{
154			SessionID:   "session2",
155			ToolName:    "file_tool",
156			Description: "Write file",
157			Action:      "write",
158			Params:      map[string]string{"file": "test.txt"},
159			Path:        "/tmp/test.txt",
160		}
161
162		events := make(chan pubsub.Event[PermissionRequest], 10)
163		service.AddListener(func(event pubsub.Event[PermissionRequest]) {
164			events <- event
165		})
166		var result1 bool
167		var wg sync.WaitGroup
168
169		wg.Go(func() {
170			result1, _ = service.Request(t.Context(), req)
171		})
172
173		var permissionReq PermissionRequest
174		event := <-events
175		permissionReq = event.Payload
176
177		service.Grant(t.Context(), permissionReq)
178		wg.Wait()
179		assert.True(t, result1, "First request should be granted")
180
181		var result2 bool
182
183		wg.Go(func() {
184			result2, _ = service.Request(t.Context(), req)
185		})
186
187		event = <-events
188		permissionReq = event.Payload
189		service.Deny(t.Context(), permissionReq)
190		wg.Wait()
191		assert.False(t, result2, "Second request should be denied")
192	})
193	t.Run("Concurrent requests with different outcomes", func(t *testing.T) {
194		service := NewPermissionService("/tmp", false, []string{})
195
196		events := make(chan pubsub.Event[PermissionRequest], 10)
197		service.AddListener(func(event pubsub.Event[PermissionRequest]) {
198			events <- event
199		})
200
201		var wg sync.WaitGroup
202		results := make([]bool, 3)
203
204		requests := []CreatePermissionRequest{
205			{
206				SessionID:   "concurrent1",
207				ToolName:    "tool1",
208				Action:      "action1",
209				Path:        "/tmp/file1.txt",
210				Description: "First concurrent request",
211			},
212			{
213				SessionID:   "concurrent2",
214				ToolName:    "tool2",
215				Action:      "action2",
216				Path:        "/tmp/file2.txt",
217				Description: "Second concurrent request",
218			},
219			{
220				SessionID:   "concurrent3",
221				ToolName:    "tool3",
222				Action:      "action3",
223				Path:        "/tmp/file3.txt",
224				Description: "Third concurrent request",
225			},
226		}
227
228		for i, req := range requests {
229			wg.Add(1)
230			go func(index int, request CreatePermissionRequest) {
231				defer wg.Done()
232				result, _ := service.Request(t.Context(), request)
233				results[index] = result
234			}(i, req)
235		}
236
237		for range 3 {
238			event := <-events
239			switch event.Payload.ToolName {
240			case "tool1":
241				service.Grant(t.Context(), event.Payload)
242			case "tool2":
243				service.GrantPersistent(t.Context(), event.Payload)
244			case "tool3":
245				service.Deny(t.Context(), event.Payload)
246			}
247		}
248		wg.Wait()
249		grantedCount := 0
250		for _, result := range results {
251			if result {
252				grantedCount++
253			}
254		}
255
256		assert.Equal(t, 2, grantedCount, "Should have 2 granted and 1 denied")
257		secondReq := requests[1]
258		secondReq.Description = "Repeat of second request"
259		result, err := service.Request(t.Context(), secondReq)
260		require.NoError(t, err)
261		assert.True(t, result, "Repeated request should be auto-approved due to persistent permission")
262	})
263}