v0.27.1 c78ce83

handle_permission_test.go RawHistoryFolder

1package usecase_test
2
3import (
4	"context"
5	"testing"
6	"time"
7
8	"github.com/bnema/dumber/internal/application/port"
9	portmocks "github.com/bnema/dumber/internal/application/port/mocks"
10	"github.com/bnema/dumber/internal/application/usecase"
11	"github.com/bnema/dumber/internal/domain/entity"
12	repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
13	"github.com/rs/zerolog"
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/mock"
16	"github.com/stretchr/testify/require"
17)
18
19func permissionLoggerFromContext(ctx context.Context) *zerolog.Logger {
20	return zerolog.Ctx(ctx)
21}
22
23func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) {
24	ctx := testContext()
25	permRepo := repomocks.NewMockPermissionRepository(t)
26	dialog := portmocks.NewMockPermissionDialogPresenter(t)
27
28	uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
29
30	permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay).
31		Return(nil, nil)
32
33	allowed := false
34	denied := false
35	callback := usecase.PermissionCallback{
36		Allow: func() { allowed = true },
37		Deny:  func() { denied = true },
38	}
39
40	// Display capture should be auto-allowed (portal handles UI)
41	uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
42		entity.PermissionTypeDisplay,
43	}, callback)
44
45	assert.True(t, allowed, "display capture should be auto-allowed")
46	assert.False(t, denied)
47	dialog.AssertNotCalled(t, "ShowPermissionDialog")
48}
49
50func TestHandlePermissionUseCase_AutoAllowDeviceInfo(t *testing.T) {
51	ctx := testContext()
52	permRepo := repomocks.NewMockPermissionRepository(t)
53	dialog := portmocks.NewMockPermissionDialogPresenter(t)
54
55	uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
56
57	permRepo.EXPECT().Get(mock.Anything, "https://example.com", entity.PermissionTypeDeviceInfo).
58		Return(nil, nil)
59
60	allowed := false
61	callback := usecase.PermissionCallback{
62		Allow: func() { allowed = true },
63		Deny:  func() {},
64	}
65
66	// Device info should be auto-allowed
67	uc.HandlePermissionRequest(ctx, "https://example.com", []entity.PermissionType{
68		entity.PermissionTypeDeviceInfo,
69	}, callback)
70
71	assert.True(t, allowed, "device info should be auto-allowed")
72}
73
74func TestHandlePermissionUseCase_StoredPermissionGranted(t *testing.T) {
75	ctx := testContext()
76	permRepo := repomocks.NewMockPermissionRepository(t)
77	dialog := portmocks.NewMockPermissionDialogPresenter(t)
78
79	uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
80
81	// Return stored granted permission
82	permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
83		Return(&entity.PermissionRecord{
84			Origin:    "https://meet.example.com",
85			Type:      entity.PermissionTypeMicrophone,
86			Decision:  entity.PermissionGranted,
87			UpdatedAt: time.Now().Unix(),
88		}, nil)
89
90	allowed := false
91	callback := usecase.PermissionCallback{
92		Allow: func() { allowed = true },
93		Deny:  func() {},
94	}
95
96	uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
97		entity.PermissionTypeMicrophone,
98	}, callback)
99
100	assert.True(t, allowed, "should use stored granted permission")
101	dialog.AssertNotCalled(t, "ShowPermissionDialog")
102}
103
104func TestHandlePermissionUseCase_StoredPermissionDenied(t *testing.T) {
105	ctx := testContext()
106	permRepo := repomocks.NewMockPermissionRepository(t)
107	dialog := portmocks.NewMockPermissionDialogPresenter(t)
108
109	uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
110
111	// Return stored denied permission
112	permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera).
113		Return(&entity.PermissionRecord{
114			Origin:    "https://meet.example.com",
115			Type:      entity.PermissionTypeCamera,
116			Decision:  entity.PermissionDenied,
117			UpdatedAt: time.Now().Unix(),
118		}, nil)
119
120	denied := false
121	callback := usecase.PermissionCallback{
122		Allow: func() {},
123		Deny:  func() { denied = true },
124	}
125
126	uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
127		entity.PermissionTypeCamera,
128	}, callback)
129
130	assert.True(t, denied, "should use stored denied permission")
131	dialog.AssertNotCalled(t, "ShowPermissionDialog")
132}
133
134func TestHandlePermissionUseCase_ShowDialogForPrompt(t *testing.T) {
135	ctx := testContext()
136	permRepo := repomocks.NewMockPermissionRepository(t)
137	dialog := portmocks.NewMockPermissionDialogPresenter(t)
138
139	uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
140
141	// No stored permission (returns nil)
142	permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
143		Return(nil, nil)
144
145	allowed := false
146	callback := usecase.PermissionCallback{
147		Allow: func() { allowed = true },
148		Deny:  func() {},
149	}
150
151	// Dialog shows and user clicks Allow (not persistent)
152	dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", []entity.PermissionType{
153		entity.PermissionTypeMicrophone,
154	}, mock.Anything).Run(func(_ context.Context, _ string, _ []entity.PermissionType, callback func(port.PermissionDialogResult)) {
155		callback(port.PermissionDialogResult{Allowed: true, Persistent: false})
156	})
157
158	uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
159		entity.PermissionTypeMicrophone,
160	}, callback)
161
162	assert.True(t, allowed, "user allowed permission")
163	// Should not persist since Persistent=false
164	permRepo.AssertNotCalled(t, "Set")
165}
166
167func TestHandlePermissionUseCase_PersistAllowed(t *testing.T) {
168	ctx := testContext()
169	permRepo := repomocks.NewMockPermissionRepository(t)
170	dialog := portmocks.NewMockPermissionDialogPresenter(t)
171
172	uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
173
174	// No stored permission
175	permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
176		Return(nil, nil)
177
178	allowed := false
179	callback := usecase.PermissionCallback{
180		Allow: func() { allowed = true },
181		Deny:  func() {},
182	}
183
184	// Dialog shows and user clicks "Always Allow"
185	dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", []entity.PermissionType{
186		entity.PermissionTypeMicrophone,
187	}, mock.Anything).Run(func(_ context.Context, _ string, _ []entity.PermissionType, cb func(port.PermissionDialogResult)) {
188		cb(port.PermissionDialogResult{Allowed: true, Persistent: true})
189	})
190
191	// Should persist the granted permission
192	permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")).
193		Run(func(_ context.Context, r *entity.PermissionRecord) {
194			assert.Equal(t, "https://meet.example.com", r.Origin)
195			assert.Equal(t, entity.PermissionTypeMicrophone, r.Type)
196			assert.Equal(t, entity.PermissionGranted, r.Decision)
197		}).Return(nil)
198
199	uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
200		entity.PermissionTypeMicrophone,
201	}, callback)
202
203	assert.True(t, allowed)
204}
205
206func TestHandlePermissionUseCase_PersistDenied(t *testing.T) {
207	ctx := testContext()
208	permRepo := repomocks.NewMockPermissionRepository(t)
209	dialog := portmocks.NewMockPermissionDialogPresenter(t)
210
211	uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
212
213	// No stored permission
214	permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera).
215		Return(nil, nil)
216
217	denied := false
218	callback := usecase.PermissionCallback{
219		Allow: func() {},
220		Deny:  func() { denied = true },
221	}
222
223	// Dialog shows and user clicks "Always Deny"
224	dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", []entity.PermissionType{
225		entity.PermissionTypeCamera,
226	}, mock.Anything).Run(func(_ context.Context, _ string, _ []entity.PermissionType, cb func(port.PermissionDialogResult)) {
227		// User denied but wants to persist this decision
228		cb(port.PermissionDialogResult{Allowed: false, Persistent: true})
229	})
230
231	// Should persist the denied permission
232	permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")).
233		Run(func(_ context.Context, r *entity.PermissionRecord) {
234			assert.Equal(t, entity.PermissionDenied, r.Decision)
235		}).Return(nil)
236
237	uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
238		entity.PermissionTypeCamera,
239	}, callback)
240
241	assert.True(t, denied)
242}
243
244func TestHandlePermissionUseCase_EmptyOrigin(t *testing.T) {
245	ctx := testContext()
246	permRepo := repomocks.NewMockPermissionRepository(t)
247	dialog := portmocks.NewMockPermissionDialogPresenter(t)
248
249	uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
250
251	denied := false
252	callback := usecase.PermissionCallback{
253		Allow: func() {},
254		Deny:  func() { denied = true },
255	}
256
257	// Empty origin should be denied
258	uc.HandlePermissionRequest(ctx, "", []entity.PermissionType{
259		entity.PermissionTypeMicrophone,
260	}, callback)
261
262	assert.True(t, denied, "empty origin should be denied")
263}
264
265func TestHandlePermissionUseCase_CombinedPermissions(t *testing.T) {
266	ctx := testContext()
267	permRepo := repomocks.NewMockPermissionRepository(t)
268	dialog := portmocks.NewMockPermissionDialogPresenter(t)
269
270	uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
271
272	// Both mic and camera - one denied means whole request denied
273	permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
274		Return(&entity.PermissionRecord{
275			Origin:   "https://meet.example.com",
276			Type:     entity.PermissionTypeMicrophone,
277			Decision: entity.PermissionGranted,
278		}, nil)
279
280	permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera).
281		Return(&entity.PermissionRecord{
282			Origin:   "https://meet.example.com",
283			Type:     entity.PermissionTypeCamera,
284			Decision: entity.PermissionDenied,
285		}, nil)
286
287	denied := false
288	callback := usecase.PermissionCallback{
289		Allow: func() {},
290		Deny:  func() { denied = true },
291	}
292
293	uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
294		entity.PermissionTypeMicrophone,
295		entity.PermissionTypeCamera,
296	}, callback)
297
298	assert.True(t, denied, "if any permission is denied, whole request is denied")
299}
300
301func TestHandlePermissionUseCase_QueryPermissionState_AutoAllow(t *testing.T) {
302	ctx := testContext()
303	permRepo := repomocks.NewMockPermissionRepository(t)
304	dialog := portmocks.NewMockPermissionDialogPresenter(t)
305
306	uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
307	permRepo.EXPECT().Get(mock.Anything, "https://example.com", entity.PermissionTypeDisplay).
308		Return(nil, nil)
309
310	state := uc.QueryPermissionState(ctx, "https://example.com", entity.PermissionTypeDisplay)
311
312	assert.Equal(t, entity.PermissionGranted, state, "display capture should auto-return granted")
313}
314
315func TestHandlePermissionUseCase_QueryPermissionState_AutoAllowManualOverride(t *testing.T) {
316	ctx := testContext()
317	permRepo := repomocks.NewMockPermissionRepository(t)
318	dialog := portmocks.NewMockPermissionDialogPresenter(t)
319
320	uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
321
322	permRepo.EXPECT().Get(mock.Anything, "https://example.com", entity.PermissionTypeDisplay).
323		Return(&entity.PermissionRecord{
324			Origin:   "https://example.com",
325			Type:     entity.PermissionTypeDisplay,
326			Decision: entity.PermissionDenied,
327		}, nil)
328
329	state := uc.QueryPermissionState(ctx, "https://example.com", entity.PermissionTypeDisplay)
330
331	assert.Equal(t, entity.PermissionDenied, state, "stored manual deny should override auto-allow")
332}
333
334func TestHandlePermissionUseCase_QueryPermissionState_Stored(t *testing.T) {
335	ctx := testContext()
336	permRepo := repomocks.NewMockPermissionRepository(t)
337	dialog := portmocks.NewMockPermissionDialogPresenter(t)
338
339	uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
340
341	permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
342		Return(&entity.PermissionRecord{
343			Origin:   "https://meet.example.com",
344			Type:     entity.PermissionTypeMicrophone,
345			Decision: entity.PermissionDenied,
346		}, nil)
347
348	state := uc.QueryPermissionState(ctx, "https://meet.example.com", entity.PermissionTypeMicrophone)
349
350	assert.Equal(t, entity.PermissionDenied, state)
351}
352
353func TestHandlePermissionUseCase_QueryPermissionState_NoRecord(t *testing.T) {
354	ctx := testContext()
355	permRepo := repomocks.NewMockPermissionRepository(t)
356	dialog := portmocks.NewMockPermissionDialogPresenter(t)
357
358	uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
359
360	permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera).
361		Return(nil, nil)
362
363	state := uc.QueryPermissionState(ctx, "https://meet.example.com", entity.PermissionTypeCamera)
364
365	assert.Equal(t, entity.PermissionPrompt, state, "no record should return prompt")
366}
367
368func TestHandlePermissionUseCase_QueryPermissionState_EmptyOriginReturnsPrompt(t *testing.T) {
369	ctx := testContext()
370	permRepo := repomocks.NewMockPermissionRepository(t)
371	dialog := portmocks.NewMockPermissionDialogPresenter(t)
372
373	uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
374
375	state := uc.QueryPermissionState(ctx, "", entity.PermissionTypeDisplay)
376
377	assert.Equal(t, entity.PermissionPrompt, state, "empty origin should return prompt")
378	permRepo.AssertNotCalled(t, "Get")
379}
380
381func TestHandlePermissionUseCase_NonPersistableNotSaved(t *testing.T) {
382	ctx := testContext()
383	permRepo := repomocks.NewMockPermissionRepository(t)
384	dialog := portmocks.NewMockPermissionDialogPresenter(t)
385
386	uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
387
388	// Display capture is not persistable per W3C spec
389	// A stored override may exist, so a Get call is expected before auto-allow.
390	// No Set call should happen even if we try to persist
391	permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay).
392		Return(nil, nil)
393
394	allowed := false
395	callback := usecase.PermissionCallback{
396		Allow: func() { allowed = true },
397		Deny:  func() {},
398	}
399
400	uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
401		entity.PermissionTypeDisplay,
402	}, callback)
403
404	assert.True(t, allowed)
405	permRepo.AssertNotCalled(t, "Set")
406}
407
408func TestHandlePermissionUseCase_NoDialogPresenter(t *testing.T) {
409	ctx := testContext()
410	permRepo := repomocks.NewMockPermissionRepository(t)
411
412	// Create use case with nil dialog presenter
413	uc := usecase.NewHandlePermissionUseCase(permRepo, nil, permissionLoggerFromContext)
414
415	denied := false
416	callback := usecase.PermissionCallback{
417		Allow: func() {},
418		Deny:  func() { denied = true },
419	}
420
421	// Mic request with no stored permission and no dialog should be denied
422	permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
423		Return(nil, nil)
424
425	uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
426		entity.PermissionTypeMicrophone,
427	}, callback)
428
429	assert.True(t, denied, "should deny when no dialog presenter is available")
430}
431
432func TestHandlePermissionUseCase_DisplayOverrideDenied(t *testing.T) {
433	ctx := testContext()
434	permRepo := repomocks.NewMockPermissionRepository(t)
435	dialog := portmocks.NewMockPermissionDialogPresenter(t)
436
437	uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
438
439	permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeDisplay).
440		Return(&entity.PermissionRecord{
441			Origin:   "https://meet.example.com",
442			Type:     entity.PermissionTypeDisplay,
443			Decision: entity.PermissionDenied,
444		}, nil)
445
446	denied := false
447	callback := usecase.PermissionCallback{
448		Allow: func() {},
449		Deny:  func() { denied = true },
450	}
451
452	uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
453		entity.PermissionTypeDisplay,
454	}, callback)
455
456	assert.True(t, denied, "display should be denied when manual override is denied")
457	dialog.AssertNotCalled(t, "ShowPermissionDialog")
458}
459
460func TestHandlePermissionUseCase_GetSetResetManualDecision(t *testing.T) {
461	ctx := testContext()
462	permRepo := repomocks.NewMockPermissionRepository(t)
463	uc := usecase.NewHandlePermissionUseCase(permRepo, nil, permissionLoggerFromContext)
464
465	permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")).
466		Run(func(_ context.Context, record *entity.PermissionRecord) {
467			assert.Equal(t, "https://app.zoom.us", record.Origin)
468			assert.Equal(t, entity.PermissionTypeMicrophone, record.Type)
469			assert.Equal(t, entity.PermissionGranted, record.Decision)
470		}).
471		Return(nil)
472
473	err := uc.SetManualPermissionDecision(
474		ctx,
475		"https://app.zoom.us",
476		entity.PermissionTypeMicrophone,
477		entity.PermissionGranted,
478	)
479	require.NoError(t, err)
480
481	permRepo.EXPECT().Get(mock.Anything, "https://app.zoom.us", entity.PermissionTypeMicrophone).
482		Return(&entity.PermissionRecord{
483			Origin:   "https://app.zoom.us",
484			Type:     entity.PermissionTypeMicrophone,
485			Decision: entity.PermissionGranted,
486		}, nil)
487
488	record, err := uc.GetManualPermissionDecision(ctx, "https://app.zoom.us", entity.PermissionTypeMicrophone)
489	require.NoError(t, err)
490	if assert.NotNil(t, record) {
491		assert.Equal(t, entity.PermissionGranted, record.Decision)
492	}
493
494	permRepo.EXPECT().Delete(mock.Anything, "https://app.zoom.us", entity.PermissionTypeMicrophone).
495		Return(nil)
496	err = uc.ResetManualPermissionDecision(ctx, "https://app.zoom.us", entity.PermissionTypeMicrophone)
497	require.NoError(t, err)
498}
499
500func TestHandlePermissionUseCase_SetManualPermissionDecision_NonPersistable(t *testing.T) {
501	ctx := testContext()
502	permRepo := repomocks.NewMockPermissionRepository(t)
503	uc := usecase.NewHandlePermissionUseCase(permRepo, nil, permissionLoggerFromContext)
504
505	err := uc.SetManualPermissionDecision(
506		ctx,
507		"https://app.zoom.us",
508		entity.PermissionTypeDisplay,
509		entity.PermissionDenied,
510	)
511
512	require.Error(t, err)
513	require.ErrorContains(t, err, "permission type not persistable")
514	permRepo.AssertNotCalled(t, "Set")
515	permRepo.AssertNotCalled(t, "Delete")
516}
517
518func TestHandlePermissionUseCase_UsesInjectedLoggerFactory(t *testing.T) {
519	ctx := testContext()
520	permRepo := repomocks.NewMockPermissionRepository(t)
521	dialog := portmocks.NewMockPermissionDialogPresenter(t)
522
523	called := false
524	loggerFactory := func(ctx context.Context) *zerolog.Logger {
525		called = true
526		return zerolog.Ctx(ctx)
527	}
528
529	uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, loggerFactory)
530	uc.HandlePermissionRequest(ctx, "", []entity.PermissionType{entity.PermissionTypeMicrophone}, usecase.PermissionCallback{
531		Allow: func() {},
532		Deny:  func() {},
533	})
534
535	assert.True(t, called)
536}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close