feat(permission): add domain entities and repository interfaces
Add PermissionType, PermissionDecision, and PermissionRecord entities with W3C-compliant persistence rules (CanPersist, IsAutoAllow). Add PermissionRepository interface for storage abstraction.
- commit
- 13fbcf4c782a87355b9efd58fcc8faf3e0874589
- author
- brice · · 2026-01-30 05:01 UTC
- committer
- brice · · 2026-02-07 11:53 UTC
- parent
- 64ad4fc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +308 -0
A internal/domain/entity/permission.go +118 -0
| @@ -0,0 +1,118 @@ | |||
| 1 | + | package entity | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ) | |
| 6 | + | ||
| 7 | + | // PermissionType represents the type of permission being requested. | |
| 8 | + | type PermissionType string | |
| 9 | + | ||
| 10 | + | const ( | |
| 11 | + | // PermissionTypeMicrophone represents microphone access permission. | |
| 12 | + | PermissionTypeMicrophone PermissionType = "microphone" | |
| 13 | + | ||
| 14 | + | // PermissionTypeCamera represents camera access permission. | |
| 15 | + | PermissionTypeCamera PermissionType = "camera" | |
| 16 | + | ||
| 17 | + | // PermissionTypeMicrophoneAndCamera represents combined mic and camera permission. | |
| 18 | + | PermissionTypeMicrophoneAndCamera PermissionType = "microphone_and_camera" | |
| 19 | + | ||
| 20 | + | // PermissionTypeDisplay represents screen sharing/display capture permission. | |
| 21 | + | PermissionTypeDisplay PermissionType = "display" | |
| 22 | + | ||
| 23 | + | // PermissionTypeDeviceInfo represents device enumeration permission. | |
| 24 | + | PermissionTypeDeviceInfo PermissionType = "device_info" | |
| 25 | + | ||
| 26 | + | // PermissionTypeClipboard represents clipboard access permission. | |
| 27 | + | PermissionTypeClipboard PermissionType = "clipboard" | |
| 28 | + | ||
| 29 | + | // PermissionTypeNotification represents notification permission. | |
| 30 | + | PermissionTypeNotification PermissionType = "notification" | |
| 31 | + | ||
| 32 | + | // PermissionTypeGeolocation represents geolocation permission. | |
| 33 | + | PermissionTypeGeolocation PermissionType = "geolocation" | |
| 34 | + | ||
| 35 | + | // PermissionTypePointerLock represents pointer lock permission. | |
| 36 | + | PermissionTypePointerLock PermissionType = "pointer_lock" | |
| 37 | + | ||
| 38 | + | // PermissionTypeMediaKeySystem represents DRM/media key system permission. | |
| 39 | + | PermissionTypeMediaKeySystem PermissionType = "media_key_system" | |
| 40 | + | ||
| 41 | + | // PermissionTypeWebsiteDataAccess represents 3rd party cookie/data access permission. | |
| 42 | + | PermissionTypeWebsiteDataAccess PermissionType = "website_data_access" | |
| 43 | + | ) | |
| 44 | + | ||
| 45 | + | // PermissionDecision represents the user's decision for a permission. | |
| 46 | + | type PermissionDecision string | |
| 47 | + | ||
| 48 | + | const ( | |
| 49 | + | // PermissionGranted means the permission was allowed. | |
| 50 | + | PermissionGranted PermissionDecision = "granted" | |
| 51 | + | ||
| 52 | + | // PermissionDenied means the permission was denied. | |
| 53 | + | PermissionDenied PermissionDecision = "denied" | |
| 54 | + | ||
| 55 | + | // PermissionPrompt means no decision has been made yet (default state). | |
| 56 | + | PermissionPrompt PermissionDecision = "prompt" | |
| 57 | + | ) | |
| 58 | + | ||
| 59 | + | // PermissionRecord stores a permission decision for a specific origin and type. | |
| 60 | + | type PermissionRecord struct { | |
| 61 | + | Origin string // The origin (domain) this permission applies to | |
| 62 | + | Type PermissionType // The type of permission | |
| 63 | + | Decision PermissionDecision // The decision: granted, denied, or prompt | |
| 64 | + | UpdatedAt time.Time // When this record was last updated | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // IsGranted returns true if the permission is granted. | |
| 68 | + | func (p *PermissionRecord) IsGranted() bool { | |
| 69 | + | return p.Decision == PermissionGranted | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // IsDenied returns true if the permission is denied. | |
| 73 | + | func (p *PermissionRecord) IsDenied() bool { | |
| 74 | + | return p.Decision == PermissionDenied | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // PermissionSet represents multiple permission decisions for a single request. | |
| 78 | + | // This is used when handling combined permission requests (e.g., mic + camera). | |
| 79 | + | type PermissionSet struct { | |
| 80 | + | Types []PermissionType | |
| 81 | + | Decision PermissionDecision | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // CanPersist returns true if this permission type can be persisted. | |
| 85 | + | // Per W3C spec, display capture permissions cannot be persisted. | |
| 86 | + | func CanPersist(permType PermissionType) bool { | |
| 87 | + | switch permType { | |
| 88 | + | case PermissionTypeDisplay: | |
| 89 | + | return false // W3C forbids persisting display capture permissions | |
| 90 | + | case PermissionTypeDeviceInfo, PermissionTypePointerLock: | |
| 91 | + | return false // Auto-allowed, no need to persist | |
| 92 | + | default: | |
| 93 | + | return true | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // IsAutoAllow returns true if this permission type should be auto-allowed. | |
| 98 | + | func IsAutoAllow(permType PermissionType) bool { | |
| 99 | + | switch permType { | |
| 100 | + | case PermissionTypeDisplay: | |
| 101 | + | return true // Portal handles the UI | |
| 102 | + | case PermissionTypeDeviceInfo: | |
| 103 | + | return true // Low risk, just lists devices | |
| 104 | + | case PermissionTypePointerLock: | |
| 105 | + | return true // Low risk, default WebKit behavior | |
| 106 | + | default: | |
| 107 | + | return false | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // PermissionTypesToStrings converts permission types to strings for logging. | |
| 112 | + | func PermissionTypesToStrings(types []PermissionType) []string { | |
| 113 | + | result := make([]string, len(types)) | |
| 114 | + | for i, t := range types { | |
| 115 | + | result[i] = string(t) | |
| 116 | + | } | |
| 117 | + | return result | |
| 118 | + | } | |
| @@ -0,0 +1,118 @@ | |||||
| 1 | + | package entity | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "time" | |||
| 5 | + | ) | |||
| 6 | + | ||||
| 7 | + | // PermissionType represents the type of permission being requested. | |||
| 8 | + | type PermissionType string | |||
| 9 | + | ||||
| 10 | + | const ( | |||
| 11 | + | // PermissionTypeMicrophone represents microphone access permission. | |||
| 12 | + | PermissionTypeMicrophone PermissionType = "microphone" | |||
| 13 | + | ||||
| 14 | + | // PermissionTypeCamera represents camera access permission. | |||
| 15 | + | PermissionTypeCamera PermissionType = "camera" | |||
| 16 | + | ||||
| 17 | + | // PermissionTypeMicrophoneAndCamera represents combined mic and camera permission. | |||
| 18 | + | PermissionTypeMicrophoneAndCamera PermissionType = "microphone_and_camera" | |||
| 19 | + | ||||
| 20 | + | // PermissionTypeDisplay represents screen sharing/display capture permission. | |||
| 21 | + | PermissionTypeDisplay PermissionType = "display" | |||
| 22 | + | ||||
| 23 | + | // PermissionTypeDeviceInfo represents device enumeration permission. | |||
| 24 | + | PermissionTypeDeviceInfo PermissionType = "device_info" | |||
| 25 | + | ||||
| 26 | + | // PermissionTypeClipboard represents clipboard access permission. | |||
| 27 | + | PermissionTypeClipboard PermissionType = "clipboard" | |||
| 28 | + | ||||
| 29 | + | // PermissionTypeNotification represents notification permission. | |||
| 30 | + | PermissionTypeNotification PermissionType = "notification" | |||
| 31 | + | ||||
| 32 | + | // PermissionTypeGeolocation represents geolocation permission. | |||
| 33 | + | PermissionTypeGeolocation PermissionType = "geolocation" | |||
| 34 | + | ||||
| 35 | + | // PermissionTypePointerLock represents pointer lock permission. | |||
| 36 | + | PermissionTypePointerLock PermissionType = "pointer_lock" | |||
| 37 | + | ||||
| 38 | + | // PermissionTypeMediaKeySystem represents DRM/media key system permission. | |||
| 39 | + | PermissionTypeMediaKeySystem PermissionType = "media_key_system" | |||
| 40 | + | ||||
| 41 | + | // PermissionTypeWebsiteDataAccess represents 3rd party cookie/data access permission. | |||
| 42 | + | PermissionTypeWebsiteDataAccess PermissionType = "website_data_access" | |||
| 43 | + | ) | |||
| 44 | + | ||||
| 45 | + | // PermissionDecision represents the user's decision for a permission. | |||
| 46 | + | type PermissionDecision string | |||
| 47 | + | ||||
| 48 | + | const ( | |||
| 49 | + | // PermissionGranted means the permission was allowed. | |||
| 50 | + | PermissionGranted PermissionDecision = "granted" | |||
| 51 | + | ||||
| 52 | + | // PermissionDenied means the permission was denied. | |||
| 53 | + | PermissionDenied PermissionDecision = "denied" | |||
| 54 | + | ||||
| 55 | + | // PermissionPrompt means no decision has been made yet (default state). | |||
| 56 | + | PermissionPrompt PermissionDecision = "prompt" | |||
| 57 | + | ) | |||
| 58 | + | ||||
| 59 | + | // PermissionRecord stores a permission decision for a specific origin and type. | |||
| 60 | + | type PermissionRecord struct { | |||
| 61 | + | Origin string // The origin (domain) this permission applies to | |||
| 62 | + | Type PermissionType // The type of permission | |||
| 63 | + | Decision PermissionDecision // The decision: granted, denied, or prompt | |||
| 64 | + | UpdatedAt time.Time // When this record was last updated | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // IsGranted returns true if the permission is granted. | |||
| 68 | + | func (p *PermissionRecord) IsGranted() bool { | |||
| 69 | + | return p.Decision == PermissionGranted | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // IsDenied returns true if the permission is denied. | |||
| 73 | + | func (p *PermissionRecord) IsDenied() bool { | |||
| 74 | + | return p.Decision == PermissionDenied | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // PermissionSet represents multiple permission decisions for a single request. | |||
| 78 | + | // This is used when handling combined permission requests (e.g., mic + camera). | |||
| 79 | + | type PermissionSet struct { | |||
| 80 | + | Types []PermissionType | |||
| 81 | + | Decision PermissionDecision | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // CanPersist returns true if this permission type can be persisted. | |||
| 85 | + | // Per W3C spec, display capture permissions cannot be persisted. | |||
| 86 | + | func CanPersist(permType PermissionType) bool { | |||
| 87 | + | switch permType { | |||
| 88 | + | case PermissionTypeDisplay: | |||
| 89 | + | return false // W3C forbids persisting display capture permissions | |||
| 90 | + | case PermissionTypeDeviceInfo, PermissionTypePointerLock: | |||
| 91 | + | return false // Auto-allowed, no need to persist | |||
| 92 | + | default: | |||
| 93 | + | return true | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // IsAutoAllow returns true if this permission type should be auto-allowed. | |||
| 98 | + | func IsAutoAllow(permType PermissionType) bool { | |||
| 99 | + | switch permType { | |||
| 100 | + | case PermissionTypeDisplay: | |||
| 101 | + | return true // Portal handles the UI | |||
| 102 | + | case PermissionTypeDeviceInfo: | |||
| 103 | + | return true // Low risk, just lists devices | |||
| 104 | + | case PermissionTypePointerLock: | |||
| 105 | + | return true // Low risk, default WebKit behavior | |||
| 106 | + | default: | |||
| 107 | + | return false | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // PermissionTypesToStrings converts permission types to strings for logging. | |||
| 112 | + | func PermissionTypesToStrings(types []PermissionType) []string { | |||
| 113 | + | result := make([]string, len(types)) | |||
| 114 | + | for i, t := range types { | |||
| 115 | + | result[i] = string(t) | |||
| 116 | + | } | |||
| 117 | + | return result | |||
| 118 | + | } | |||
A internal/domain/entity/permission_test.go +166 -0
| @@ -0,0 +1,166 @@ | |||
| 1 | + | package entity_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestPermissionType_Constants(t *testing.T) { | |
| 12 | + | tests := []struct { | |
| 13 | + | permType entity.PermissionType | |
| 14 | + | expected string | |
| 15 | + | }{ | |
| 16 | + | {entity.PermissionTypeMicrophone, "microphone"}, | |
| 17 | + | {entity.PermissionTypeCamera, "camera"}, | |
| 18 | + | {entity.PermissionTypeMicrophoneAndCamera, "microphone_and_camera"}, | |
| 19 | + | {entity.PermissionTypeDisplay, "display"}, | |
| 20 | + | {entity.PermissionTypeDeviceInfo, "device_info"}, | |
| 21 | + | {entity.PermissionTypeClipboard, "clipboard"}, | |
| 22 | + | {entity.PermissionTypeNotification, "notification"}, | |
| 23 | + | {entity.PermissionTypeGeolocation, "geolocation"}, | |
| 24 | + | {entity.PermissionTypePointerLock, "pointer_lock"}, | |
| 25 | + | {entity.PermissionTypeMediaKeySystem, "media_key_system"}, | |
| 26 | + | {entity.PermissionTypeWebsiteDataAccess, "website_data_access"}, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | for _, tt := range tests { | |
| 30 | + | t.Run(string(tt.permType), func(t *testing.T) { | |
| 31 | + | assert.Equal(t, tt.expected, string(tt.permType)) | |
| 32 | + | }) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestPermissionDecision_Constants(t *testing.T) { | |
| 37 | + | tests := []struct { | |
| 38 | + | decision entity.PermissionDecision | |
| 39 | + | expected string | |
| 40 | + | }{ | |
| 41 | + | {entity.PermissionGranted, "granted"}, | |
| 42 | + | {entity.PermissionDenied, "denied"}, | |
| 43 | + | {entity.PermissionPrompt, "prompt"}, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | for _, tt := range tests { | |
| 47 | + | t.Run(string(tt.decision), func(t *testing.T) { | |
| 48 | + | assert.Equal(t, tt.expected, string(tt.decision)) | |
| 49 | + | }) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestPermissionRecord_IsGranted(t *testing.T) { | |
| 54 | + | tests := []struct { | |
| 55 | + | name string | |
| 56 | + | decision entity.PermissionDecision | |
| 57 | + | expected bool | |
| 58 | + | }{ | |
| 59 | + | {"granted", entity.PermissionGranted, true}, | |
| 60 | + | {"denied", entity.PermissionDenied, false}, | |
| 61 | + | {"prompt", entity.PermissionPrompt, false}, | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | for _, tt := range tests { | |
| 65 | + | t.Run(tt.name, func(t *testing.T) { | |
| 66 | + | record := &entity.PermissionRecord{ | |
| 67 | + | Origin: "https://example.com", | |
| 68 | + | Type: entity.PermissionTypeMicrophone, | |
| 69 | + | Decision: tt.decision, | |
| 70 | + | UpdatedAt: time.Now(), | |
| 71 | + | } | |
| 72 | + | assert.Equal(t, tt.expected, record.IsGranted()) | |
| 73 | + | }) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestPermissionRecord_IsDenied(t *testing.T) { | |
| 78 | + | tests := []struct { | |
| 79 | + | name string | |
| 80 | + | decision entity.PermissionDecision | |
| 81 | + | expected bool | |
| 82 | + | }{ | |
| 83 | + | {"granted", entity.PermissionGranted, false}, | |
| 84 | + | {"denied", entity.PermissionDenied, true}, | |
| 85 | + | {"prompt", entity.PermissionPrompt, false}, | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | for _, tt := range tests { | |
| 89 | + | t.Run(tt.name, func(t *testing.T) { | |
| 90 | + | record := &entity.PermissionRecord{ | |
| 91 | + | Origin: "https://example.com", | |
| 92 | + | Type: entity.PermissionTypeCamera, | |
| 93 | + | Decision: tt.decision, | |
| 94 | + | UpdatedAt: time.Now(), | |
| 95 | + | } | |
| 96 | + | assert.Equal(t, tt.expected, record.IsDenied()) | |
| 97 | + | }) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestCanPersist(t *testing.T) { | |
| 102 | + | tests := []struct { | |
| 103 | + | name string | |
| 104 | + | permType entity.PermissionType | |
| 105 | + | expected bool | |
| 106 | + | }{ | |
| 107 | + | // Non-persistable per W3C spec | |
| 108 | + | {"display capture", entity.PermissionTypeDisplay, false}, | |
| 109 | + | {"device info", entity.PermissionTypeDeviceInfo, false}, | |
| 110 | + | {"pointer lock", entity.PermissionTypePointerLock, false}, | |
| 111 | + | ||
| 112 | + | // Persistable | |
| 113 | + | {"microphone", entity.PermissionTypeMicrophone, true}, | |
| 114 | + | {"camera", entity.PermissionTypeCamera, true}, | |
| 115 | + | {"microphone and camera", entity.PermissionTypeMicrophoneAndCamera, true}, | |
| 116 | + | {"clipboard", entity.PermissionTypeClipboard, true}, | |
| 117 | + | {"notification", entity.PermissionTypeNotification, true}, | |
| 118 | + | {"geolocation", entity.PermissionTypeGeolocation, true}, | |
| 119 | + | {"media key system", entity.PermissionTypeMediaKeySystem, true}, | |
| 120 | + | {"website data access", entity.PermissionTypeWebsiteDataAccess, true}, | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | for _, tt := range tests { | |
| 124 | + | t.Run(tt.name, func(t *testing.T) { | |
| 125 | + | assert.Equal(t, tt.expected, entity.CanPersist(tt.permType)) | |
| 126 | + | }) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestIsAutoAllow(t *testing.T) { | |
| 131 | + | tests := []struct { | |
| 132 | + | name string | |
| 133 | + | permType entity.PermissionType | |
| 134 | + | expected bool | |
| 135 | + | }{ | |
| 136 | + | // Auto-allowed (portal handles UI or low risk) | |
| 137 | + | {"display capture", entity.PermissionTypeDisplay, true}, | |
| 138 | + | {"device info", entity.PermissionTypeDeviceInfo, true}, | |
| 139 | + | {"pointer lock", entity.PermissionTypePointerLock, true}, | |
| 140 | + | ||
| 141 | + | // Not auto-allowed (require user consent) | |
| 142 | + | {"microphone", entity.PermissionTypeMicrophone, false}, | |
| 143 | + | {"camera", entity.PermissionTypeCamera, false}, | |
| 144 | + | {"clipboard", entity.PermissionTypeClipboard, false}, | |
| 145 | + | {"notification", entity.PermissionTypeNotification, false}, | |
| 146 | + | {"geolocation", entity.PermissionTypeGeolocation, false}, | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | for _, tt := range tests { | |
| 150 | + | t.Run(tt.name, func(t *testing.T) { | |
| 151 | + | assert.Equal(t, tt.expected, entity.IsAutoAllow(tt.permType)) | |
| 152 | + | }) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestPermissionSet(t *testing.T) { | |
| 157 | + | set := entity.PermissionSet{ | |
| 158 | + | Types: []entity.PermissionType{entity.PermissionTypeMicrophone, entity.PermissionTypeCamera}, | |
| 159 | + | Decision: entity.PermissionGranted, | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | assert.Len(t, set.Types, 2) | |
| 163 | + | assert.Contains(t, set.Types, entity.PermissionTypeMicrophone) | |
| 164 | + | assert.Contains(t, set.Types, entity.PermissionTypeCamera) | |
| 165 | + | assert.Equal(t, entity.PermissionGranted, set.Decision) | |
| 166 | + | } | |
| @@ -0,0 +1,166 @@ | |||||
| 1 | + | package entity_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestPermissionType_Constants(t *testing.T) { | |||
| 12 | + | tests := []struct { | |||
| 13 | + | permType entity.PermissionType | |||
| 14 | + | expected string | |||
| 15 | + | }{ | |||
| 16 | + | {entity.PermissionTypeMicrophone, "microphone"}, | |||
| 17 | + | {entity.PermissionTypeCamera, "camera"}, | |||
| 18 | + | {entity.PermissionTypeMicrophoneAndCamera, "microphone_and_camera"}, | |||
| 19 | + | {entity.PermissionTypeDisplay, "display"}, | |||
| 20 | + | {entity.PermissionTypeDeviceInfo, "device_info"}, | |||
| 21 | + | {entity.PermissionTypeClipboard, "clipboard"}, | |||
| 22 | + | {entity.PermissionTypeNotification, "notification"}, | |||
| 23 | + | {entity.PermissionTypeGeolocation, "geolocation"}, | |||
| 24 | + | {entity.PermissionTypePointerLock, "pointer_lock"}, | |||
| 25 | + | {entity.PermissionTypeMediaKeySystem, "media_key_system"}, | |||
| 26 | + | {entity.PermissionTypeWebsiteDataAccess, "website_data_access"}, | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | for _, tt := range tests { | |||
| 30 | + | t.Run(string(tt.permType), func(t *testing.T) { | |||
| 31 | + | assert.Equal(t, tt.expected, string(tt.permType)) | |||
| 32 | + | }) | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestPermissionDecision_Constants(t *testing.T) { | |||
| 37 | + | tests := []struct { | |||
| 38 | + | decision entity.PermissionDecision | |||
| 39 | + | expected string | |||
| 40 | + | }{ | |||
| 41 | + | {entity.PermissionGranted, "granted"}, | |||
| 42 | + | {entity.PermissionDenied, "denied"}, | |||
| 43 | + | {entity.PermissionPrompt, "prompt"}, | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | for _, tt := range tests { | |||
| 47 | + | t.Run(string(tt.decision), func(t *testing.T) { | |||
| 48 | + | assert.Equal(t, tt.expected, string(tt.decision)) | |||
| 49 | + | }) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestPermissionRecord_IsGranted(t *testing.T) { | |||
| 54 | + | tests := []struct { | |||
| 55 | + | name string | |||
| 56 | + | decision entity.PermissionDecision | |||
| 57 | + | expected bool | |||
| 58 | + | }{ | |||
| 59 | + | {"granted", entity.PermissionGranted, true}, | |||
| 60 | + | {"denied", entity.PermissionDenied, false}, | |||
| 61 | + | {"prompt", entity.PermissionPrompt, false}, | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | for _, tt := range tests { | |||
| 65 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 66 | + | record := &entity.PermissionRecord{ | |||
| 67 | + | Origin: "https://example.com", | |||
| 68 | + | Type: entity.PermissionTypeMicrophone, | |||
| 69 | + | Decision: tt.decision, | |||
| 70 | + | UpdatedAt: time.Now(), | |||
| 71 | + | } | |||
| 72 | + | assert.Equal(t, tt.expected, record.IsGranted()) | |||
| 73 | + | }) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestPermissionRecord_IsDenied(t *testing.T) { | |||
| 78 | + | tests := []struct { | |||
| 79 | + | name string | |||
| 80 | + | decision entity.PermissionDecision | |||
| 81 | + | expected bool | |||
| 82 | + | }{ | |||
| 83 | + | {"granted", entity.PermissionGranted, false}, | |||
| 84 | + | {"denied", entity.PermissionDenied, true}, | |||
| 85 | + | {"prompt", entity.PermissionPrompt, false}, | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | for _, tt := range tests { | |||
| 89 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 90 | + | record := &entity.PermissionRecord{ | |||
| 91 | + | Origin: "https://example.com", | |||
| 92 | + | Type: entity.PermissionTypeCamera, | |||
| 93 | + | Decision: tt.decision, | |||
| 94 | + | UpdatedAt: time.Now(), | |||
| 95 | + | } | |||
| 96 | + | assert.Equal(t, tt.expected, record.IsDenied()) | |||
| 97 | + | }) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestCanPersist(t *testing.T) { | |||
| 102 | + | tests := []struct { | |||
| 103 | + | name string | |||
| 104 | + | permType entity.PermissionType | |||
| 105 | + | expected bool | |||
| 106 | + | }{ | |||
| 107 | + | // Non-persistable per W3C spec | |||
| 108 | + | {"display capture", entity.PermissionTypeDisplay, false}, | |||
| 109 | + | {"device info", entity.PermissionTypeDeviceInfo, false}, | |||
| 110 | + | {"pointer lock", entity.PermissionTypePointerLock, false}, | |||
| 111 | + | ||||
| 112 | + | // Persistable | |||
| 113 | + | {"microphone", entity.PermissionTypeMicrophone, true}, | |||
| 114 | + | {"camera", entity.PermissionTypeCamera, true}, | |||
| 115 | + | {"microphone and camera", entity.PermissionTypeMicrophoneAndCamera, true}, | |||
| 116 | + | {"clipboard", entity.PermissionTypeClipboard, true}, | |||
| 117 | + | {"notification", entity.PermissionTypeNotification, true}, | |||
| 118 | + | {"geolocation", entity.PermissionTypeGeolocation, true}, | |||
| 119 | + | {"media key system", entity.PermissionTypeMediaKeySystem, true}, | |||
| 120 | + | {"website data access", entity.PermissionTypeWebsiteDataAccess, true}, | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | for _, tt := range tests { | |||
| 124 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 125 | + | assert.Equal(t, tt.expected, entity.CanPersist(tt.permType)) | |||
| 126 | + | }) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestIsAutoAllow(t *testing.T) { | |||
| 131 | + | tests := []struct { | |||
| 132 | + | name string | |||
| 133 | + | permType entity.PermissionType | |||
| 134 | + | expected bool | |||
| 135 | + | }{ | |||
| 136 | + | // Auto-allowed (portal handles UI or low risk) | |||
| 137 | + | {"display capture", entity.PermissionTypeDisplay, true}, | |||
| 138 | + | {"device info", entity.PermissionTypeDeviceInfo, true}, | |||
| 139 | + | {"pointer lock", entity.PermissionTypePointerLock, true}, | |||
| 140 | + | ||||
| 141 | + | // Not auto-allowed (require user consent) | |||
| 142 | + | {"microphone", entity.PermissionTypeMicrophone, false}, | |||
| 143 | + | {"camera", entity.PermissionTypeCamera, false}, | |||
| 144 | + | {"clipboard", entity.PermissionTypeClipboard, false}, | |||
| 145 | + | {"notification", entity.PermissionTypeNotification, false}, | |||
| 146 | + | {"geolocation", entity.PermissionTypeGeolocation, false}, | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | for _, tt := range tests { | |||
| 150 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 151 | + | assert.Equal(t, tt.expected, entity.IsAutoAllow(tt.permType)) | |||
| 152 | + | }) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func TestPermissionSet(t *testing.T) { | |||
| 157 | + | set := entity.PermissionSet{ | |||
| 158 | + | Types: []entity.PermissionType{entity.PermissionTypeMicrophone, entity.PermissionTypeCamera}, | |||
| 159 | + | Decision: entity.PermissionGranted, | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | assert.Len(t, set.Types, 2) | |||
| 163 | + | assert.Contains(t, set.Types, entity.PermissionTypeMicrophone) | |||
| 164 | + | assert.Contains(t, set.Types, entity.PermissionTypeCamera) | |||
| 165 | + | assert.Equal(t, entity.PermissionGranted, set.Decision) | |||
| 166 | + | } | |||
A internal/domain/repository/permission.go +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | package repository | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // PermissionRepository defines operations for permission persistence. | |
| 10 | + | // Only microphone and camera permissions are persisted per W3C spec. | |
| 11 | + | type PermissionRepository interface { | |
| 12 | + | // Get retrieves the permission record for a specific origin and permission type. | |
| 13 | + | // Returns nil if no record exists (treat as "prompt" state). | |
| 14 | + | Get(ctx context.Context, origin string, permType entity.PermissionType) (*entity.PermissionRecord, error) | |
| 15 | + | ||
| 16 | + | // Set saves or updates a permission record. | |
| 17 | + | Set(ctx context.Context, record *entity.PermissionRecord) error | |
| 18 | + | ||
| 19 | + | // Delete removes a permission record for a specific origin and type. | |
| 20 | + | Delete(ctx context.Context, origin string, permType entity.PermissionType) error | |
| 21 | + | ||
| 22 | + | // GetAll retrieves all permission records for an origin. | |
| 23 | + | GetAll(ctx context.Context, origin string) ([]*entity.PermissionRecord, error) | |
| 24 | + | } | |
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | package repository | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // PermissionRepository defines operations for permission persistence. | |||
| 10 | + | // Only microphone and camera permissions are persisted per W3C spec. | |||
| 11 | + | type PermissionRepository interface { | |||
| 12 | + | // Get retrieves the permission record for a specific origin and permission type. | |||
| 13 | + | // Returns nil if no record exists (treat as "prompt" state). | |||
| 14 | + | Get(ctx context.Context, origin string, permType entity.PermissionType) (*entity.PermissionRecord, error) | |||
| 15 | + | ||||
| 16 | + | // Set saves or updates a permission record. | |||
| 17 | + | Set(ctx context.Context, record *entity.PermissionRecord) error | |||
| 18 | + | ||||
| 19 | + | // Delete removes a permission record for a specific origin and type. | |||
| 20 | + | Delete(ctx context.Context, origin string, permType entity.PermissionType) error | |||
| 21 | + | ||||
| 22 | + | // GetAll retrieves all permission records for an origin. | |||
| 23 | + | GetAll(ctx context.Context, origin string) ([]*entity.PermissionRecord, error) | |||
| 24 | + | } | |||