detached 664884d

feat(permission): add use case and dialog presenter interfaces

Add HandlePermissionUseCase with auto-allow, stored permission checking,
dialog flow, and persistence logic.
Add PermissionDialogPresenter interface for UI abstraction.
Add comprehensive unit tests covering all permission scenarios.
Include origin extraction helper for URI normalization.
commit
664884d2f14a560877e41986fd49a8ac3a11165b
author
brice · · 2026-01-30 05:01 UTC
committer
brice · · 2026-02-07 11:53 UTC
parent
de0845e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +734 -0
A internal/application/port/permission.go +36 -0
@@ -0,0 +1,36 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+// PermissionDialogResult represents the user's response from a permission dialog.
10+type PermissionDialogResult struct {
11+ // Allowed is true if the user clicked "Allow" or "Always Allow".
12+ Allowed bool
13+
14+ // Persistent is true if the user clicked "Always Allow" (save this decision).
15+ // This should only be true for mic/camera permissions, not display capture.
16+ Persistent bool
17+}
18+
19+// PermissionDialogPresenter defines the interface for showing permission dialogs.
20+// This is implemented by the UI layer (Adwaita dialogs).
21+type PermissionDialogPresenter interface {
22+ // ShowPermissionDialog displays a permission request dialog to the user.
23+ // The callback is invoked with the user's decision.
24+ //
25+ // Parameters:
26+ // - ctx: context for cancellation and logging
27+ // - origin: the website origin requesting permission (e.g., "https://meet.example.com")
28+ // - permTypes: the types of permissions being requested
29+ // - callback: invoked when the user makes a decision or dialog is dismissed
30+ ShowPermissionDialog(
31+ ctx context.Context,
32+ origin string,
33+ permTypes []entity.PermissionType,
34+ callback func(result PermissionDialogResult),
35+ )
36+}
@@ -0,0 +1,36 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+// PermissionDialogResult represents the user's response from a permission dialog.
10+type PermissionDialogResult struct {
11+ // Allowed is true if the user clicked "Allow" or "Always Allow".
12+ Allowed bool
13+
14+ // Persistent is true if the user clicked "Always Allow" (save this decision).
15+ // This should only be true for mic/camera permissions, not display capture.
16+ Persistent bool
17+}
18+
19+// PermissionDialogPresenter defines the interface for showing permission dialogs.
20+// This is implemented by the UI layer (Adwaita dialogs).
21+type PermissionDialogPresenter interface {
22+ // ShowPermissionDialog displays a permission request dialog to the user.
23+ // The callback is invoked with the user's decision.
24+ //
25+ // Parameters:
26+ // - ctx: context for cancellation and logging
27+ // - origin: the website origin requesting permission (e.g., "https://meet.example.com")
28+ // - permTypes: the types of permissions being requested
29+ // - callback: invoked when the user makes a decision or dialog is dismissed
30+ ShowPermissionDialog(
31+ ctx context.Context,
32+ origin string,
33+ permTypes []entity.PermissionType,
34+ callback func(result PermissionDialogResult),
35+ )
36+}
A internal/application/usecase/handle_permission.go +287 -0
@@ -0,0 +1,287 @@
1+// Package usecase contains application use cases that orchestrate domain logic.
2+package usecase
3+
4+import (
5+ "context"
6+ "fmt"
7+ "net/url"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/domain/repository"
13+ "github.com/bnema/dumber/internal/logging"
14+)
15+
16+// PermissionCallback provides allow/deny functions for the permission request.
17+type PermissionCallback struct {
18+ Allow func()
19+ Deny func()
20+}
21+
22+// HandlePermissionUseCase handles media permission requests from WebKit.
23+// It implements the permission strategy defined in the architecture:
24+// - Display capture: auto-allow (XDG portal handles UI)
25+// - Device enumeration: auto-allow (low risk)
26+// - Mic/Camera: check stored → dialog → persist if "Always"
27+type HandlePermissionUseCase struct {
28+ permRepo repository.PermissionRepository
29+ dialog port.PermissionDialogPresenter
30+}
31+
32+// NewHandlePermissionUseCase creates a new permission handling use case.
33+func NewHandlePermissionUseCase(
34+ permRepo repository.PermissionRepository,
35+ dialog port.PermissionDialogPresenter,
36+) *HandlePermissionUseCase {
37+ return &HandlePermissionUseCase{
38+ permRepo: permRepo,
39+ dialog: dialog,
40+ }
41+}
42+
43+// SetDialogPresenter sets the dialog presenter. This can be called after
44+// initialization when the UI window is available.
45+func (uc *HandlePermissionUseCase) SetDialogPresenter(dialog port.PermissionDialogPresenter) {
46+ uc.dialog = dialog
47+}
48+
49+// HandlePermissionRequest processes a permission request from WebKit.
50+// This is the main entry point for the permission use case.
51+//
52+// Parameters:
53+// - ctx: context for cancellation and logging
54+// - origin: the website origin (URI) requesting permission
55+// - permTypes: the types of permissions being requested
56+// - callback: callbacks to allow or deny the request (must call one)
57+func (uc *HandlePermissionUseCase) HandlePermissionRequest(
58+ ctx context.Context,
59+ origin string,
60+ permTypes []entity.PermissionType,
61+ callback PermissionCallback,
62+) {
63+ log := logging.FromContext(ctx).With().
64+ Str("component", "permission").
65+ Str("origin", origin).
66+ Strs("types", entity.PermissionTypesToStrings(permTypes)).
67+ Logger()
68+
69+ // Validate origin
70+ if origin == "" {
71+ log.Warn().Msg("permission request with empty origin, denying")
72+ callback.Deny()
73+ return
74+ }
75+
76+ // Check if all types are auto-allow
77+ if uc.isAutoAllowSet(permTypes) {
78+ log.Debug().Msg("auto-allowing permission request")
79+ callback.Allow()
80+ return
81+ }
82+
83+ // For mic/camera: check stored permissions first
84+ decision := uc.checkStoredPermissions(ctx, origin, permTypes)
85+ switch decision {
86+ case entity.PermissionGranted:
87+ log.Debug().Msg("using stored permission: granted")
88+ callback.Allow()
89+ return
90+ case entity.PermissionDenied:
91+ log.Debug().Msg("using stored permission: denied")
92+ callback.Deny()
93+ return
94+ case entity.PermissionPrompt:
95+ // Fall through to show dialog
96+ }
97+
98+ // Show dialog for undecided permissions
99+ uc.showPermissionDialog(ctx, origin, permTypes, callback)
100+}
101+
102+// QueryPermissionState returns the current permission state for the W3C Permissions API.
103+// This is used by websites to check if they already have permission before calling getUserMedia().
104+func (uc *HandlePermissionUseCase) QueryPermissionState(
105+ ctx context.Context,
106+ origin string,
107+ permType entity.PermissionType,
108+) entity.PermissionDecision {
109+ log := logging.FromContext(ctx).With().
110+ Str("component", "permission").
111+ Str("origin", origin).
112+ Str("type", string(permType)).
113+ Logger()
114+
115+ // Auto-allow types
116+ if entity.IsAutoAllow(permType) {
117+ log.Debug().Msg("query: auto-allow type returns granted")
118+ return entity.PermissionGranted
119+ }
120+
121+ // Check stored permission
122+ record, err := uc.permRepo.Get(ctx, origin, permType)
123+ if err != nil {
124+ log.Warn().Err(err).Msg("query: failed to get stored permission, returning prompt")
125+ return entity.PermissionPrompt
126+ }
127+
128+ if record == nil {
129+ log.Debug().Msg("query: no stored permission, returning prompt")
130+ return entity.PermissionPrompt
131+ }
132+
133+ log.Debug().Str("decision", string(record.Decision)).Msg("query: returning stored permission")
134+ return record.Decision
135+}
136+
137+// isAutoAllowSet returns true if all permission types in the set are auto-allow.
138+func (uc *HandlePermissionUseCase) isAutoAllowSet(permTypes []entity.PermissionType) bool {
139+ _ = uc // suppress unused receiver warning, method kept for consistency
140+ for _, pt := range permTypes {
141+ if !entity.IsAutoAllow(pt) {
142+ return false
143+ }
144+ }
145+ return true
146+}
147+
148+// checkStoredPermissions checks if all permissions in the set have stored decisions.
149+// Returns granted if all are granted, denied if any are denied, prompt otherwise.
150+func (uc *HandlePermissionUseCase) checkStoredPermissions(
151+ ctx context.Context,
152+ origin string,
153+ permTypes []entity.PermissionType,
154+) entity.PermissionDecision {
155+ log := logging.FromContext(ctx)
156+
157+ hasPrompt := false
158+
159+ for _, permType := range permTypes {
160+ // Skip auto-allow types
161+ if entity.IsAutoAllow(permType) {
162+ continue
163+ }
164+
165+ // Skip non-persistable types (shouldn't happen, but defensive)
166+ if !entity.CanPersist(permType) {
167+ continue
168+ }
169+
170+ record, err := uc.permRepo.Get(ctx, origin, permType)
171+ if err != nil {
172+ log.Warn().
173+ Err(err).
174+ Str("perm_type", string(permType)).
175+ Msg("failed to get stored permission")
176+ // On error, treat as prompt (fall back to dialog)
177+ hasPrompt = true
178+ continue
179+ }
180+
181+ if record == nil {
182+ hasPrompt = true
183+ continue
184+ }
185+
186+ switch record.Decision {
187+ case entity.PermissionDenied:
188+ // If any permission is denied, deny the whole request (conservative)
189+ return entity.PermissionDenied
190+ case entity.PermissionPrompt:
191+ hasPrompt = true
192+ }
193+ }
194+
195+ if hasPrompt {
196+ return entity.PermissionPrompt
197+ }
198+
199+ // All permissions are granted
200+ return entity.PermissionGranted
201+}
202+
203+// showPermissionDialog shows the permission dialog and handles the result.
204+func (uc *HandlePermissionUseCase) showPermissionDialog(
205+ ctx context.Context,
206+ origin string,
207+ permTypes []entity.PermissionType,
208+ callback PermissionCallback,
209+) {
210+ log := logging.FromContext(ctx)
211+
212+ // If no dialog presenter is set, deny the permission
213+ if uc.dialog == nil {
214+ log.Warn().Str("origin", origin).Msg("no dialog presenter available, denying permission")
215+ callback.Deny()
216+ return
217+ }
218+
219+ uc.dialog.ShowPermissionDialog(ctx, origin, permTypes, func(result port.PermissionDialogResult) {
220+ if result.Allowed {
221+ log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission")
222+ callback.Allow()
223+
224+ // Persist if "Always Allow" was chosen
225+ if result.Persistent {
226+ uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted)
227+ }
228+ } else {
229+ log.Debug().Msg("user denied permission")
230+ callback.Deny()
231+
232+ // Persist denial if "Always Deny" was chosen (Persistent=true + Allowed=false)
233+ if result.Persistent {
234+ uc.persistPermission(ctx, origin, permTypes, entity.PermissionDenied)
235+ }
236+ }
237+ })
238+}
239+
240+// persistPermission saves the permission decision to the repository.
241+func (uc *HandlePermissionUseCase) persistPermission(
242+ ctx context.Context,
243+ origin string,
244+ permTypes []entity.PermissionType,
245+ decision entity.PermissionDecision,
246+) {
247+ log := logging.FromContext(ctx)
248+
249+ for _, permType := range permTypes {
250+ // Only persist allowed types
251+ if !entity.CanPersist(permType) {
252+ continue
253+ }
254+
255+ record := &entity.PermissionRecord{
256+ Origin: origin,
257+ Type: permType,
258+ Decision: decision,
259+ UpdatedAt: time.Now(),
260+ }
261+
262+ if err := uc.permRepo.Set(ctx, record); err != nil {
263+ log.Warn().
264+ Err(err).
265+ Str("perm_type", string(permType)).
266+ Str("decision", string(decision)).
267+ Msg("failed to persist permission")
268+ }
269+ }
270+}
271+
272+// ExtractOrigin extracts the origin (scheme://host) from a URI.
273+// This normalizes URIs to origins for permission storage.
274+func ExtractOrigin(uri string) (string, error) {
275+ u, err := url.Parse(uri)
276+ if err != nil {
277+ return "", fmt.Errorf("invalid URI: %w", err)
278+ }
279+
280+ if u.Scheme == "" || u.Host == "" {
281+ return "", fmt.Errorf("URI missing scheme or host: %s", uri)
282+ }
283+
284+ // Return scheme://host (without path, query, fragment)
285+ origin := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
286+ return origin, nil
287+}
@@ -0,0 +1,287 @@
1+// Package usecase contains application use cases that orchestrate domain logic.
2+package usecase
3+
4+import (
5+ "context"
6+ "fmt"
7+ "net/url"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/domain/repository"
13+ "github.com/bnema/dumber/internal/logging"
14+)
15+
16+// PermissionCallback provides allow/deny functions for the permission request.
17+type PermissionCallback struct {
18+ Allow func()
19+ Deny func()
20+}
21+
22+// HandlePermissionUseCase handles media permission requests from WebKit.
23+// It implements the permission strategy defined in the architecture:
24+// - Display capture: auto-allow (XDG portal handles UI)
25+// - Device enumeration: auto-allow (low risk)
26+// - Mic/Camera: check stored → dialog → persist if "Always"
27+type HandlePermissionUseCase struct {
28+ permRepo repository.PermissionRepository
29+ dialog port.PermissionDialogPresenter
30+}
31+
32+// NewHandlePermissionUseCase creates a new permission handling use case.
33+func NewHandlePermissionUseCase(
34+ permRepo repository.PermissionRepository,
35+ dialog port.PermissionDialogPresenter,
36+) *HandlePermissionUseCase {
37+ return &HandlePermissionUseCase{
38+ permRepo: permRepo,
39+ dialog: dialog,
40+ }
41+}
42+
43+// SetDialogPresenter sets the dialog presenter. This can be called after
44+// initialization when the UI window is available.
45+func (uc *HandlePermissionUseCase) SetDialogPresenter(dialog port.PermissionDialogPresenter) {
46+ uc.dialog = dialog
47+}
48+
49+// HandlePermissionRequest processes a permission request from WebKit.
50+// This is the main entry point for the permission use case.
51+//
52+// Parameters:
53+// - ctx: context for cancellation and logging
54+// - origin: the website origin (URI) requesting permission
55+// - permTypes: the types of permissions being requested
56+// - callback: callbacks to allow or deny the request (must call one)
57+func (uc *HandlePermissionUseCase) HandlePermissionRequest(
58+ ctx context.Context,
59+ origin string,
60+ permTypes []entity.PermissionType,
61+ callback PermissionCallback,
62+) {
63+ log := logging.FromContext(ctx).With().
64+ Str("component", "permission").
65+ Str("origin", origin).
66+ Strs("types", entity.PermissionTypesToStrings(permTypes)).
67+ Logger()
68+
69+ // Validate origin
70+ if origin == "" {
71+ log.Warn().Msg("permission request with empty origin, denying")
72+ callback.Deny()
73+ return
74+ }
75+
76+ // Check if all types are auto-allow
77+ if uc.isAutoAllowSet(permTypes) {
78+ log.Debug().Msg("auto-allowing permission request")
79+ callback.Allow()
80+ return
81+ }
82+
83+ // For mic/camera: check stored permissions first
84+ decision := uc.checkStoredPermissions(ctx, origin, permTypes)
85+ switch decision {
86+ case entity.PermissionGranted:
87+ log.Debug().Msg("using stored permission: granted")
88+ callback.Allow()
89+ return
90+ case entity.PermissionDenied:
91+ log.Debug().Msg("using stored permission: denied")
92+ callback.Deny()
93+ return
94+ case entity.PermissionPrompt:
95+ // Fall through to show dialog
96+ }
97+
98+ // Show dialog for undecided permissions
99+ uc.showPermissionDialog(ctx, origin, permTypes, callback)
100+}
101+
102+// QueryPermissionState returns the current permission state for the W3C Permissions API.
103+// This is used by websites to check if they already have permission before calling getUserMedia().
104+func (uc *HandlePermissionUseCase) QueryPermissionState(
105+ ctx context.Context,
106+ origin string,
107+ permType entity.PermissionType,
108+) entity.PermissionDecision {
109+ log := logging.FromContext(ctx).With().
110+ Str("component", "permission").
111+ Str("origin", origin).
112+ Str("type", string(permType)).
113+ Logger()
114+
115+ // Auto-allow types
116+ if entity.IsAutoAllow(permType) {
117+ log.Debug().Msg("query: auto-allow type returns granted")
118+ return entity.PermissionGranted
119+ }
120+
121+ // Check stored permission
122+ record, err := uc.permRepo.Get(ctx, origin, permType)
123+ if err != nil {
124+ log.Warn().Err(err).Msg("query: failed to get stored permission, returning prompt")
125+ return entity.PermissionPrompt
126+ }
127+
128+ if record == nil {
129+ log.Debug().Msg("query: no stored permission, returning prompt")
130+ return entity.PermissionPrompt
131+ }
132+
133+ log.Debug().Str("decision", string(record.Decision)).Msg("query: returning stored permission")
134+ return record.Decision
135+}
136+
137+// isAutoAllowSet returns true if all permission types in the set are auto-allow.
138+func (uc *HandlePermissionUseCase) isAutoAllowSet(permTypes []entity.PermissionType) bool {
139+ _ = uc // suppress unused receiver warning, method kept for consistency
140+ for _, pt := range permTypes {
141+ if !entity.IsAutoAllow(pt) {
142+ return false
143+ }
144+ }
145+ return true
146+}
147+
148+// checkStoredPermissions checks if all permissions in the set have stored decisions.
149+// Returns granted if all are granted, denied if any are denied, prompt otherwise.
150+func (uc *HandlePermissionUseCase) checkStoredPermissions(
151+ ctx context.Context,
152+ origin string,
153+ permTypes []entity.PermissionType,
154+) entity.PermissionDecision {
155+ log := logging.FromContext(ctx)
156+
157+ hasPrompt := false
158+
159+ for _, permType := range permTypes {
160+ // Skip auto-allow types
161+ if entity.IsAutoAllow(permType) {
162+ continue
163+ }
164+
165+ // Skip non-persistable types (shouldn't happen, but defensive)
166+ if !entity.CanPersist(permType) {
167+ continue
168+ }
169+
170+ record, err := uc.permRepo.Get(ctx, origin, permType)
171+ if err != nil {
172+ log.Warn().
173+ Err(err).
174+ Str("perm_type", string(permType)).
175+ Msg("failed to get stored permission")
176+ // On error, treat as prompt (fall back to dialog)
177+ hasPrompt = true
178+ continue
179+ }
180+
181+ if record == nil {
182+ hasPrompt = true
183+ continue
184+ }
185+
186+ switch record.Decision {
187+ case entity.PermissionDenied:
188+ // If any permission is denied, deny the whole request (conservative)
189+ return entity.PermissionDenied
190+ case entity.PermissionPrompt:
191+ hasPrompt = true
192+ }
193+ }
194+
195+ if hasPrompt {
196+ return entity.PermissionPrompt
197+ }
198+
199+ // All permissions are granted
200+ return entity.PermissionGranted
201+}
202+
203+// showPermissionDialog shows the permission dialog and handles the result.
204+func (uc *HandlePermissionUseCase) showPermissionDialog(
205+ ctx context.Context,
206+ origin string,
207+ permTypes []entity.PermissionType,
208+ callback PermissionCallback,
209+) {
210+ log := logging.FromContext(ctx)
211+
212+ // If no dialog presenter is set, deny the permission
213+ if uc.dialog == nil {
214+ log.Warn().Str("origin", origin).Msg("no dialog presenter available, denying permission")
215+ callback.Deny()
216+ return
217+ }
218+
219+ uc.dialog.ShowPermissionDialog(ctx, origin, permTypes, func(result port.PermissionDialogResult) {
220+ if result.Allowed {
221+ log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission")
222+ callback.Allow()
223+
224+ // Persist if "Always Allow" was chosen
225+ if result.Persistent {
226+ uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted)
227+ }
228+ } else {
229+ log.Debug().Msg("user denied permission")
230+ callback.Deny()
231+
232+ // Persist denial if "Always Deny" was chosen (Persistent=true + Allowed=false)
233+ if result.Persistent {
234+ uc.persistPermission(ctx, origin, permTypes, entity.PermissionDenied)
235+ }
236+ }
237+ })
238+}
239+
240+// persistPermission saves the permission decision to the repository.
241+func (uc *HandlePermissionUseCase) persistPermission(
242+ ctx context.Context,
243+ origin string,
244+ permTypes []entity.PermissionType,
245+ decision entity.PermissionDecision,
246+) {
247+ log := logging.FromContext(ctx)
248+
249+ for _, permType := range permTypes {
250+ // Only persist allowed types
251+ if !entity.CanPersist(permType) {
252+ continue
253+ }
254+
255+ record := &entity.PermissionRecord{
256+ Origin: origin,
257+ Type: permType,
258+ Decision: decision,
259+ UpdatedAt: time.Now(),
260+ }
261+
262+ if err := uc.permRepo.Set(ctx, record); err != nil {
263+ log.Warn().
264+ Err(err).
265+ Str("perm_type", string(permType)).
266+ Str("decision", string(decision)).
267+ Msg("failed to persist permission")
268+ }
269+ }
270+}
271+
272+// ExtractOrigin extracts the origin (scheme://host) from a URI.
273+// This normalizes URIs to origins for permission storage.
274+func ExtractOrigin(uri string) (string, error) {
275+ u, err := url.Parse(uri)
276+ if err != nil {
277+ return "", fmt.Errorf("invalid URI: %w", err)
278+ }
279+
280+ if u.Scheme == "" || u.Host == "" {
281+ return "", fmt.Errorf("URI missing scheme or host: %s", uri)
282+ }
283+
284+ // Return scheme://host (without path, query, fragment)
285+ origin := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
286+ return origin, nil
287+}
A internal/application/usecase/handle_permission_test.go +411 -0
@@ -0,0 +1,411 @@
1+package usecase_test
2+
3+import (
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/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) {
19+ ctx := testContext()
20+ permRepo := repomocks.NewMockPermissionRepository(t)
21+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
22+
23+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
24+
25+ allowed := false
26+ denied := false
27+ callback := usecase.PermissionCallback{
28+ Allow: func() { allowed = true },
29+ Deny: func() { denied = true },
30+ }
31+
32+ // Display capture should be auto-allowed (portal handles UI)
33+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
34+ entity.PermissionTypeDisplay,
35+ }, callback)
36+
37+ assert.True(t, allowed, "display capture should be auto-allowed")
38+ assert.False(t, denied)
39+ permRepo.AssertNotCalled(t, "Get")
40+ dialog.AssertNotCalled(t, "ShowPermissionDialog")
41+}
42+
43+func TestHandlePermissionUseCase_AutoAllowDeviceInfo(t *testing.T) {
44+ ctx := testContext()
45+ permRepo := repomocks.NewMockPermissionRepository(t)
46+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
47+
48+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
49+
50+ allowed := false
51+ callback := usecase.PermissionCallback{
52+ Allow: func() { allowed = true },
53+ Deny: func() {},
54+ }
55+
56+ // Device info should be auto-allowed
57+ uc.HandlePermissionRequest(ctx, "https://example.com", []entity.PermissionType{
58+ entity.PermissionTypeDeviceInfo,
59+ }, callback)
60+
61+ assert.True(t, allowed, "device info should be auto-allowed")
62+}
63+
64+func TestHandlePermissionUseCase_StoredPermissionGranted(t *testing.T) {
65+ ctx := testContext()
66+ permRepo := repomocks.NewMockPermissionRepository(t)
67+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
68+
69+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
70+
71+ // Return stored granted permission
72+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
73+ Return(&entity.PermissionRecord{
74+ Origin: "https://meet.example.com",
75+ Type: entity.PermissionTypeMicrophone,
76+ Decision: entity.PermissionGranted,
77+ UpdatedAt: time.Now(),
78+ }, nil)
79+
80+ allowed := false
81+ callback := usecase.PermissionCallback{
82+ Allow: func() { allowed = true },
83+ Deny: func() {},
84+ }
85+
86+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
87+ entity.PermissionTypeMicrophone,
88+ }, callback)
89+
90+ assert.True(t, allowed, "should use stored granted permission")
91+ dialog.AssertNotCalled(t, "ShowPermissionDialog")
92+}
93+
94+func TestHandlePermissionUseCase_StoredPermissionDenied(t *testing.T) {
95+ ctx := testContext()
96+ permRepo := repomocks.NewMockPermissionRepository(t)
97+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
98+
99+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
100+
101+ // Return stored denied permission
102+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera).
103+ Return(&entity.PermissionRecord{
104+ Origin: "https://meet.example.com",
105+ Type: entity.PermissionTypeCamera,
106+ Decision: entity.PermissionDenied,
107+ UpdatedAt: time.Now(),
108+ }, nil)
109+
110+ denied := false
111+ callback := usecase.PermissionCallback{
112+ Allow: func() {},
113+ Deny: func() { denied = true },
114+ }
115+
116+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
117+ entity.PermissionTypeCamera,
118+ }, callback)
119+
120+ assert.True(t, denied, "should use stored denied permission")
121+ dialog.AssertNotCalled(t, "ShowPermissionDialog")
122+}
123+
124+func TestHandlePermissionUseCase_ShowDialogForPrompt(t *testing.T) {
125+ ctx := testContext()
126+ permRepo := repomocks.NewMockPermissionRepository(t)
127+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
128+
129+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
130+
131+ // No stored permission (returns nil)
132+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
133+ Return(nil, nil)
134+
135+ allowed := false
136+ callback := usecase.PermissionCallback{
137+ Allow: func() { allowed = true },
138+ Deny: func() {},
139+ }
140+
141+ // Dialog shows and user clicks Allow (not persistent)
142+ dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", []entity.PermissionType{
143+ entity.PermissionTypeMicrophone,
144+ }, mock.Anything).Run(func(_ context.Context, _ string, _ []entity.PermissionType, callback func(port.PermissionDialogResult)) {
145+ callback(port.PermissionDialogResult{Allowed: true, Persistent: false})
146+ })
147+
148+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
149+ entity.PermissionTypeMicrophone,
150+ }, callback)
151+
152+ assert.True(t, allowed, "user allowed permission")
153+ // Should not persist since Persistent=false
154+ permRepo.AssertNotCalled(t, "Set")
155+}
156+
157+func TestHandlePermissionUseCase_PersistAllowed(t *testing.T) {
158+ ctx := testContext()
159+ permRepo := repomocks.NewMockPermissionRepository(t)
160+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
161+
162+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
163+
164+ // No stored permission
165+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
166+ Return(nil, nil)
167+
168+ allowed := false
169+ callback := usecase.PermissionCallback{
170+ Allow: func() { allowed = true },
171+ Deny: func() {},
172+ }
173+
174+ // Dialog shows and user clicks "Always Allow"
175+ dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", []entity.PermissionType{
176+ entity.PermissionTypeMicrophone,
177+ }, mock.Anything).Run(func(_ context.Context, _ string, _ []entity.PermissionType, cb func(port.PermissionDialogResult)) {
178+ cb(port.PermissionDialogResult{Allowed: true, Persistent: true})
179+ })
180+
181+ // Should persist the granted permission
182+ permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")).
183+ Run(func(_ context.Context, r *entity.PermissionRecord) {
184+ assert.Equal(t, "https://meet.example.com", r.Origin)
185+ assert.Equal(t, entity.PermissionTypeMicrophone, r.Type)
186+ assert.Equal(t, entity.PermissionGranted, r.Decision)
187+ }).Return(nil)
188+
189+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
190+ entity.PermissionTypeMicrophone,
191+ }, callback)
192+
193+ assert.True(t, allowed)
194+}
195+
196+func TestHandlePermissionUseCase_PersistDenied(t *testing.T) {
197+ ctx := testContext()
198+ permRepo := repomocks.NewMockPermissionRepository(t)
199+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
200+
201+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
202+
203+ // No stored permission
204+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera).
205+ Return(nil, nil)
206+
207+ denied := false
208+ callback := usecase.PermissionCallback{
209+ Allow: func() {},
210+ Deny: func() { denied = true },
211+ }
212+
213+ // Dialog shows and user clicks "Always Deny"
214+ dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", []entity.PermissionType{
215+ entity.PermissionTypeCamera,
216+ }, mock.Anything).Run(func(_ context.Context, _ string, _ []entity.PermissionType, cb func(port.PermissionDialogResult)) {
217+ // User denied but wants to persist this decision
218+ cb(port.PermissionDialogResult{Allowed: false, Persistent: true})
219+ })
220+
221+ // Should persist the denied permission
222+ permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")).
223+ Run(func(_ context.Context, r *entity.PermissionRecord) {
224+ assert.Equal(t, entity.PermissionDenied, r.Decision)
225+ }).Return(nil)
226+
227+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
228+ entity.PermissionTypeCamera,
229+ }, callback)
230+
231+ assert.True(t, denied)
232+}
233+
234+func TestHandlePermissionUseCase_EmptyOrigin(t *testing.T) {
235+ ctx := testContext()
236+ permRepo := repomocks.NewMockPermissionRepository(t)
237+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
238+
239+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
240+
241+ denied := false
242+ callback := usecase.PermissionCallback{
243+ Allow: func() {},
244+ Deny: func() { denied = true },
245+ }
246+
247+ // Empty origin should be denied
248+ uc.HandlePermissionRequest(ctx, "", []entity.PermissionType{
249+ entity.PermissionTypeMicrophone,
250+ }, callback)
251+
252+ assert.True(t, denied, "empty origin should be denied")
253+}
254+
255+func TestHandlePermissionUseCase_CombinedPermissions(t *testing.T) {
256+ ctx := testContext()
257+ permRepo := repomocks.NewMockPermissionRepository(t)
258+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
259+
260+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
261+
262+ // Both mic and camera - one denied means whole request denied
263+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
264+ Return(&entity.PermissionRecord{
265+ Origin: "https://meet.example.com",
266+ Type: entity.PermissionTypeMicrophone,
267+ Decision: entity.PermissionGranted,
268+ }, nil)
269+
270+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera).
271+ Return(&entity.PermissionRecord{
272+ Origin: "https://meet.example.com",
273+ Type: entity.PermissionTypeCamera,
274+ Decision: entity.PermissionDenied,
275+ }, nil)
276+
277+ denied := false
278+ callback := usecase.PermissionCallback{
279+ Allow: func() {},
280+ Deny: func() { denied = true },
281+ }
282+
283+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
284+ entity.PermissionTypeMicrophone,
285+ entity.PermissionTypeCamera,
286+ }, callback)
287+
288+ assert.True(t, denied, "if any permission is denied, whole request is denied")
289+}
290+
291+func TestHandlePermissionUseCase_QueryPermissionState_AutoAllow(t *testing.T) {
292+ ctx := testContext()
293+ permRepo := repomocks.NewMockPermissionRepository(t)
294+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
295+
296+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
297+
298+ state := uc.QueryPermissionState(ctx, "https://example.com", entity.PermissionTypeDisplay)
299+
300+ assert.Equal(t, entity.PermissionGranted, state, "display capture should auto-return granted")
301+}
302+
303+func TestHandlePermissionUseCase_QueryPermissionState_Stored(t *testing.T) {
304+ ctx := testContext()
305+ permRepo := repomocks.NewMockPermissionRepository(t)
306+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
307+
308+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
309+
310+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
311+ Return(&entity.PermissionRecord{
312+ Origin: "https://meet.example.com",
313+ Type: entity.PermissionTypeMicrophone,
314+ Decision: entity.PermissionDenied,
315+ }, nil)
316+
317+ state := uc.QueryPermissionState(ctx, "https://meet.example.com", entity.PermissionTypeMicrophone)
318+
319+ assert.Equal(t, entity.PermissionDenied, state)
320+}
321+
322+func TestHandlePermissionUseCase_QueryPermissionState_NoRecord(t *testing.T) {
323+ ctx := testContext()
324+ permRepo := repomocks.NewMockPermissionRepository(t)
325+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
326+
327+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
328+
329+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera).
330+ Return(nil, nil)
331+
332+ state := uc.QueryPermissionState(ctx, "https://meet.example.com", entity.PermissionTypeCamera)
333+
334+ assert.Equal(t, entity.PermissionPrompt, state, "no record should return prompt")
335+}
336+
337+func TestHandlePermissionUseCase_NonPersistableNotSaved(t *testing.T) {
338+ ctx := testContext()
339+ permRepo := repomocks.NewMockPermissionRepository(t)
340+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
341+
342+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
343+
344+ // Display capture is not persistable per W3C spec
345+ // No Get call since it's auto-allowed
346+ // No Set call should happen even if we try to persist
347+
348+ allowed := false
349+ callback := usecase.PermissionCallback{
350+ Allow: func() { allowed = true },
351+ Deny: func() {},
352+ }
353+
354+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
355+ entity.PermissionTypeDisplay,
356+ }, callback)
357+
358+ assert.True(t, allowed)
359+ permRepo.AssertNotCalled(t, "Set")
360+}
361+
362+func TestExtractOrigin_ValidURI(t *testing.T) {
363+ tests := []struct {
364+ name string
365+ uri string
366+ expected string
367+ }{
368+ {
369+ name: "https with port",
370+ uri: "https://example.com:8443/path?query=1",
371+ expected: "https://example.com:8443",
372+ },
373+ {
374+ name: "https without port",
375+ uri: "https://example.com/path",
376+ expected: "https://example.com",
377+ },
378+ {
379+ name: "http",
380+ uri: "http://localhost:8080/app",
381+ expected: "http://localhost:8080",
382+ },
383+ }
384+
385+ for _, tt := range tests {
386+ t.Run(tt.name, func(t *testing.T) {
387+ origin, err := usecase.ExtractOrigin(tt.uri)
388+ require.NoError(t, err)
389+ assert.Equal(t, tt.expected, origin)
390+ })
391+ }
392+}
393+
394+func TestExtractOrigin_InvalidURI(t *testing.T) {
395+ tests := []struct {
396+ name string
397+ uri string
398+ }{
399+ {name: "empty", uri: ""},
400+ {name: "no scheme", uri: "example.com"},
401+ {name: "no host", uri: "https://"},
402+ }
403+
404+ for _, tt := range tests {
405+ t.Run(tt.name, func(t *testing.T) {
406+ origin, err := usecase.ExtractOrigin(tt.uri)
407+ require.Error(t, err)
408+ assert.Empty(t, origin)
409+ })
410+ }
411+}
@@ -0,0 +1,411 @@
1+package usecase_test
2+
3+import (
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/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) {
19+ ctx := testContext()
20+ permRepo := repomocks.NewMockPermissionRepository(t)
21+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
22+
23+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
24+
25+ allowed := false
26+ denied := false
27+ callback := usecase.PermissionCallback{
28+ Allow: func() { allowed = true },
29+ Deny: func() { denied = true },
30+ }
31+
32+ // Display capture should be auto-allowed (portal handles UI)
33+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
34+ entity.PermissionTypeDisplay,
35+ }, callback)
36+
37+ assert.True(t, allowed, "display capture should be auto-allowed")
38+ assert.False(t, denied)
39+ permRepo.AssertNotCalled(t, "Get")
40+ dialog.AssertNotCalled(t, "ShowPermissionDialog")
41+}
42+
43+func TestHandlePermissionUseCase_AutoAllowDeviceInfo(t *testing.T) {
44+ ctx := testContext()
45+ permRepo := repomocks.NewMockPermissionRepository(t)
46+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
47+
48+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
49+
50+ allowed := false
51+ callback := usecase.PermissionCallback{
52+ Allow: func() { allowed = true },
53+ Deny: func() {},
54+ }
55+
56+ // Device info should be auto-allowed
57+ uc.HandlePermissionRequest(ctx, "https://example.com", []entity.PermissionType{
58+ entity.PermissionTypeDeviceInfo,
59+ }, callback)
60+
61+ assert.True(t, allowed, "device info should be auto-allowed")
62+}
63+
64+func TestHandlePermissionUseCase_StoredPermissionGranted(t *testing.T) {
65+ ctx := testContext()
66+ permRepo := repomocks.NewMockPermissionRepository(t)
67+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
68+
69+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
70+
71+ // Return stored granted permission
72+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
73+ Return(&entity.PermissionRecord{
74+ Origin: "https://meet.example.com",
75+ Type: entity.PermissionTypeMicrophone,
76+ Decision: entity.PermissionGranted,
77+ UpdatedAt: time.Now(),
78+ }, nil)
79+
80+ allowed := false
81+ callback := usecase.PermissionCallback{
82+ Allow: func() { allowed = true },
83+ Deny: func() {},
84+ }
85+
86+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
87+ entity.PermissionTypeMicrophone,
88+ }, callback)
89+
90+ assert.True(t, allowed, "should use stored granted permission")
91+ dialog.AssertNotCalled(t, "ShowPermissionDialog")
92+}
93+
94+func TestHandlePermissionUseCase_StoredPermissionDenied(t *testing.T) {
95+ ctx := testContext()
96+ permRepo := repomocks.NewMockPermissionRepository(t)
97+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
98+
99+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
100+
101+ // Return stored denied permission
102+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera).
103+ Return(&entity.PermissionRecord{
104+ Origin: "https://meet.example.com",
105+ Type: entity.PermissionTypeCamera,
106+ Decision: entity.PermissionDenied,
107+ UpdatedAt: time.Now(),
108+ }, nil)
109+
110+ denied := false
111+ callback := usecase.PermissionCallback{
112+ Allow: func() {},
113+ Deny: func() { denied = true },
114+ }
115+
116+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
117+ entity.PermissionTypeCamera,
118+ }, callback)
119+
120+ assert.True(t, denied, "should use stored denied permission")
121+ dialog.AssertNotCalled(t, "ShowPermissionDialog")
122+}
123+
124+func TestHandlePermissionUseCase_ShowDialogForPrompt(t *testing.T) {
125+ ctx := testContext()
126+ permRepo := repomocks.NewMockPermissionRepository(t)
127+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
128+
129+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
130+
131+ // No stored permission (returns nil)
132+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
133+ Return(nil, nil)
134+
135+ allowed := false
136+ callback := usecase.PermissionCallback{
137+ Allow: func() { allowed = true },
138+ Deny: func() {},
139+ }
140+
141+ // Dialog shows and user clicks Allow (not persistent)
142+ dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", []entity.PermissionType{
143+ entity.PermissionTypeMicrophone,
144+ }, mock.Anything).Run(func(_ context.Context, _ string, _ []entity.PermissionType, callback func(port.PermissionDialogResult)) {
145+ callback(port.PermissionDialogResult{Allowed: true, Persistent: false})
146+ })
147+
148+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
149+ entity.PermissionTypeMicrophone,
150+ }, callback)
151+
152+ assert.True(t, allowed, "user allowed permission")
153+ // Should not persist since Persistent=false
154+ permRepo.AssertNotCalled(t, "Set")
155+}
156+
157+func TestHandlePermissionUseCase_PersistAllowed(t *testing.T) {
158+ ctx := testContext()
159+ permRepo := repomocks.NewMockPermissionRepository(t)
160+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
161+
162+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
163+
164+ // No stored permission
165+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
166+ Return(nil, nil)
167+
168+ allowed := false
169+ callback := usecase.PermissionCallback{
170+ Allow: func() { allowed = true },
171+ Deny: func() {},
172+ }
173+
174+ // Dialog shows and user clicks "Always Allow"
175+ dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", []entity.PermissionType{
176+ entity.PermissionTypeMicrophone,
177+ }, mock.Anything).Run(func(_ context.Context, _ string, _ []entity.PermissionType, cb func(port.PermissionDialogResult)) {
178+ cb(port.PermissionDialogResult{Allowed: true, Persistent: true})
179+ })
180+
181+ // Should persist the granted permission
182+ permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")).
183+ Run(func(_ context.Context, r *entity.PermissionRecord) {
184+ assert.Equal(t, "https://meet.example.com", r.Origin)
185+ assert.Equal(t, entity.PermissionTypeMicrophone, r.Type)
186+ assert.Equal(t, entity.PermissionGranted, r.Decision)
187+ }).Return(nil)
188+
189+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
190+ entity.PermissionTypeMicrophone,
191+ }, callback)
192+
193+ assert.True(t, allowed)
194+}
195+
196+func TestHandlePermissionUseCase_PersistDenied(t *testing.T) {
197+ ctx := testContext()
198+ permRepo := repomocks.NewMockPermissionRepository(t)
199+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
200+
201+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
202+
203+ // No stored permission
204+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera).
205+ Return(nil, nil)
206+
207+ denied := false
208+ callback := usecase.PermissionCallback{
209+ Allow: func() {},
210+ Deny: func() { denied = true },
211+ }
212+
213+ // Dialog shows and user clicks "Always Deny"
214+ dialog.EXPECT().ShowPermissionDialog(mock.Anything, "https://meet.example.com", []entity.PermissionType{
215+ entity.PermissionTypeCamera,
216+ }, mock.Anything).Run(func(_ context.Context, _ string, _ []entity.PermissionType, cb func(port.PermissionDialogResult)) {
217+ // User denied but wants to persist this decision
218+ cb(port.PermissionDialogResult{Allowed: false, Persistent: true})
219+ })
220+
221+ // Should persist the denied permission
222+ permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")).
223+ Run(func(_ context.Context, r *entity.PermissionRecord) {
224+ assert.Equal(t, entity.PermissionDenied, r.Decision)
225+ }).Return(nil)
226+
227+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
228+ entity.PermissionTypeCamera,
229+ }, callback)
230+
231+ assert.True(t, denied)
232+}
233+
234+func TestHandlePermissionUseCase_EmptyOrigin(t *testing.T) {
235+ ctx := testContext()
236+ permRepo := repomocks.NewMockPermissionRepository(t)
237+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
238+
239+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
240+
241+ denied := false
242+ callback := usecase.PermissionCallback{
243+ Allow: func() {},
244+ Deny: func() { denied = true },
245+ }
246+
247+ // Empty origin should be denied
248+ uc.HandlePermissionRequest(ctx, "", []entity.PermissionType{
249+ entity.PermissionTypeMicrophone,
250+ }, callback)
251+
252+ assert.True(t, denied, "empty origin should be denied")
253+}
254+
255+func TestHandlePermissionUseCase_CombinedPermissions(t *testing.T) {
256+ ctx := testContext()
257+ permRepo := repomocks.NewMockPermissionRepository(t)
258+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
259+
260+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
261+
262+ // Both mic and camera - one denied means whole request denied
263+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
264+ Return(&entity.PermissionRecord{
265+ Origin: "https://meet.example.com",
266+ Type: entity.PermissionTypeMicrophone,
267+ Decision: entity.PermissionGranted,
268+ }, nil)
269+
270+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera).
271+ Return(&entity.PermissionRecord{
272+ Origin: "https://meet.example.com",
273+ Type: entity.PermissionTypeCamera,
274+ Decision: entity.PermissionDenied,
275+ }, nil)
276+
277+ denied := false
278+ callback := usecase.PermissionCallback{
279+ Allow: func() {},
280+ Deny: func() { denied = true },
281+ }
282+
283+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
284+ entity.PermissionTypeMicrophone,
285+ entity.PermissionTypeCamera,
286+ }, callback)
287+
288+ assert.True(t, denied, "if any permission is denied, whole request is denied")
289+}
290+
291+func TestHandlePermissionUseCase_QueryPermissionState_AutoAllow(t *testing.T) {
292+ ctx := testContext()
293+ permRepo := repomocks.NewMockPermissionRepository(t)
294+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
295+
296+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
297+
298+ state := uc.QueryPermissionState(ctx, "https://example.com", entity.PermissionTypeDisplay)
299+
300+ assert.Equal(t, entity.PermissionGranted, state, "display capture should auto-return granted")
301+}
302+
303+func TestHandlePermissionUseCase_QueryPermissionState_Stored(t *testing.T) {
304+ ctx := testContext()
305+ permRepo := repomocks.NewMockPermissionRepository(t)
306+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
307+
308+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
309+
310+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeMicrophone).
311+ Return(&entity.PermissionRecord{
312+ Origin: "https://meet.example.com",
313+ Type: entity.PermissionTypeMicrophone,
314+ Decision: entity.PermissionDenied,
315+ }, nil)
316+
317+ state := uc.QueryPermissionState(ctx, "https://meet.example.com", entity.PermissionTypeMicrophone)
318+
319+ assert.Equal(t, entity.PermissionDenied, state)
320+}
321+
322+func TestHandlePermissionUseCase_QueryPermissionState_NoRecord(t *testing.T) {
323+ ctx := testContext()
324+ permRepo := repomocks.NewMockPermissionRepository(t)
325+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
326+
327+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
328+
329+ permRepo.EXPECT().Get(mock.Anything, "https://meet.example.com", entity.PermissionTypeCamera).
330+ Return(nil, nil)
331+
332+ state := uc.QueryPermissionState(ctx, "https://meet.example.com", entity.PermissionTypeCamera)
333+
334+ assert.Equal(t, entity.PermissionPrompt, state, "no record should return prompt")
335+}
336+
337+func TestHandlePermissionUseCase_NonPersistableNotSaved(t *testing.T) {
338+ ctx := testContext()
339+ permRepo := repomocks.NewMockPermissionRepository(t)
340+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
341+
342+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog)
343+
344+ // Display capture is not persistable per W3C spec
345+ // No Get call since it's auto-allowed
346+ // No Set call should happen even if we try to persist
347+
348+ allowed := false
349+ callback := usecase.PermissionCallback{
350+ Allow: func() { allowed = true },
351+ Deny: func() {},
352+ }
353+
354+ uc.HandlePermissionRequest(ctx, "https://meet.example.com", []entity.PermissionType{
355+ entity.PermissionTypeDisplay,
356+ }, callback)
357+
358+ assert.True(t, allowed)
359+ permRepo.AssertNotCalled(t, "Set")
360+}
361+
362+func TestExtractOrigin_ValidURI(t *testing.T) {
363+ tests := []struct {
364+ name string
365+ uri string
366+ expected string
367+ }{
368+ {
369+ name: "https with port",
370+ uri: "https://example.com:8443/path?query=1",
371+ expected: "https://example.com:8443",
372+ },
373+ {
374+ name: "https without port",
375+ uri: "https://example.com/path",
376+ expected: "https://example.com",
377+ },
378+ {
379+ name: "http",
380+ uri: "http://localhost:8080/app",
381+ expected: "http://localhost:8080",
382+ },
383+ }
384+
385+ for _, tt := range tests {
386+ t.Run(tt.name, func(t *testing.T) {
387+ origin, err := usecase.ExtractOrigin(tt.uri)
388+ require.NoError(t, err)
389+ assert.Equal(t, tt.expected, origin)
390+ })
391+ }
392+}
393+
394+func TestExtractOrigin_InvalidURI(t *testing.T) {
395+ tests := []struct {
396+ name string
397+ uri string
398+ }{
399+ {name: "empty", uri: ""},
400+ {name: "no scheme", uri: "example.com"},
401+ {name: "no host", uri: "https://"},
402+ }
403+
404+ for _, tt := range tests {
405+ t.Run(tt.name, func(t *testing.T) {
406+ origin, err := usecase.ExtractOrigin(tt.uri)
407+ require.Error(t, err)
408+ assert.Empty(t, origin)
409+ })
410+ }
411+}

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