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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||