A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
1package auth
2
3import (
4 "strings"
5 "testing"
6)
7
8func TestParseScope_Valid(t *testing.T) {
9 tests := []struct {
10 name string
11 scopes []string
12 expectedCount int
13 expectedType string
14 expectedName string
15 expectedActions []string
16 }{
17 {
18 name: "repository with actions",
19 scopes: []string{"repository:alice/myapp:pull,push"},
20 expectedCount: 1,
21 expectedType: "repository",
22 expectedName: "alice/myapp",
23 expectedActions: []string{"pull", "push"},
24 },
25 {
26 name: "repository without actions",
27 scopes: []string{"repository:alice/myapp"},
28 expectedCount: 1,
29 expectedType: "repository",
30 expectedName: "alice/myapp",
31 expectedActions: nil,
32 },
33 {
34 name: "wildcard repository",
35 scopes: []string{"repository:*:pull,push"},
36 expectedCount: 1,
37 expectedType: "repository",
38 expectedName: "*",
39 expectedActions: []string{"pull", "push"},
40 },
41 {
42 name: "empty scope ignored",
43 scopes: []string{""},
44 expectedCount: 0,
45 },
46 {
47 name: "multiple scopes",
48 scopes: []string{"repository:alice/app1:pull", "repository:alice/app2:push"},
49 expectedCount: 2,
50 expectedType: "repository",
51 expectedName: "alice/app1",
52 expectedActions: []string{"pull"},
53 },
54 {
55 name: "single action",
56 scopes: []string{"repository:alice/myapp:pull"},
57 expectedCount: 1,
58 expectedType: "repository",
59 expectedName: "alice/myapp",
60 expectedActions: []string{"pull"},
61 },
62 {
63 name: "three actions",
64 scopes: []string{"repository:alice/myapp:pull,push,delete"},
65 expectedCount: 1,
66 expectedType: "repository",
67 expectedName: "alice/myapp",
68 expectedActions: []string{"pull", "push", "delete"},
69 },
70 // Note: DIDs with colons cannot be used directly in scope strings due to
71 // the colon delimiter. This is a known limitation.
72 {
73 name: "empty actions string",
74 scopes: []string{"repository:alice/myapp:"},
75 expectedCount: 1,
76 expectedType: "repository",
77 expectedName: "alice/myapp",
78 expectedActions: nil,
79 },
80 }
81
82 for _, tt := range tests {
83 t.Run(tt.name, func(t *testing.T) {
84 access, err := ParseScope(tt.scopes)
85 if err != nil {
86 t.Fatalf("ParseScope() error = %v", err)
87 }
88
89 if len(access) != tt.expectedCount {
90 t.Errorf("Expected %d access entries, got %d", tt.expectedCount, len(access))
91 return
92 }
93
94 if tt.expectedCount > 0 {
95 entry := access[0]
96 if entry.Type != tt.expectedType {
97 t.Errorf("Expected type %q, got %q", tt.expectedType, entry.Type)
98 }
99 if entry.Name != tt.expectedName {
100 t.Errorf("Expected name %q, got %q", tt.expectedName, entry.Name)
101 }
102 if len(entry.Actions) != len(tt.expectedActions) {
103 t.Errorf("Expected %d actions, got %d", len(tt.expectedActions), len(entry.Actions))
104 }
105 for i, expectedAction := range tt.expectedActions {
106 if i < len(entry.Actions) && entry.Actions[i] != expectedAction {
107 t.Errorf("Expected action[%d] = %q, got %q", i, expectedAction, entry.Actions[i])
108 }
109 }
110 }
111 })
112 }
113}
114
115func TestParseScope_Invalid(t *testing.T) {
116 tests := []struct {
117 name string
118 scopes []string
119 }{
120 {
121 name: "missing colon",
122 scopes: []string{"repository"},
123 },
124 {
125 name: "too many parts",
126 scopes: []string{"repository:name:actions:extra"},
127 },
128 {
129 name: "single part only",
130 scopes: []string{"invalid"},
131 },
132 {
133 name: "four colons",
134 scopes: []string{"a:b:c:d:e"},
135 },
136 }
137
138 for _, tt := range tests {
139 t.Run(tt.name, func(t *testing.T) {
140 _, err := ParseScope(tt.scopes)
141 if err == nil {
142 t.Error("Expected error for invalid scope format")
143 }
144 if !strings.Contains(err.Error(), "invalid scope") {
145 t.Errorf("Expected error message to contain 'invalid scope', got: %v", err)
146 }
147 })
148 }
149}
150
151func TestParseScope_SpecialCharacters(t *testing.T) {
152 tests := []struct {
153 name string
154 scope string
155 expectedName string
156 }{
157 {
158 name: "hyphen in name",
159 scope: "repository:alice-bob/my-app:pull",
160 expectedName: "alice-bob/my-app",
161 },
162 {
163 name: "underscore in name",
164 scope: "repository:alice_bob/my_app:pull",
165 expectedName: "alice_bob/my_app",
166 },
167 {
168 name: "dot in name",
169 scope: "repository:alice.bsky.social/myapp:pull",
170 expectedName: "alice.bsky.social/myapp",
171 },
172 {
173 name: "numbers in name",
174 scope: "repository:user123/app456:pull",
175 expectedName: "user123/app456",
176 },
177 }
178
179 for _, tt := range tests {
180 t.Run(tt.name, func(t *testing.T) {
181 access, err := ParseScope([]string{tt.scope})
182 if err != nil {
183 t.Fatalf("ParseScope() error = %v", err)
184 }
185
186 if len(access) != 1 {
187 t.Fatalf("Expected 1 access entry, got %d", len(access))
188 }
189
190 if access[0].Name != tt.expectedName {
191 t.Errorf("Expected name %q, got %q", tt.expectedName, access[0].Name)
192 }
193 })
194 }
195}
196
197func TestParseScope_MultipleScopes(t *testing.T) {
198 scopes := []string{
199 "repository:alice/app1:pull",
200 "repository:alice/app2:push",
201 "repository:bob/app3:pull,push",
202 }
203
204 access, err := ParseScope(scopes)
205 if err != nil {
206 t.Fatalf("ParseScope() error = %v", err)
207 }
208
209 if len(access) != 3 {
210 t.Fatalf("Expected 3 access entries, got %d", len(access))
211 }
212
213 // Verify first entry
214 if access[0].Name != "alice/app1" {
215 t.Errorf("Expected first name %q, got %q", "alice/app1", access[0].Name)
216 }
217 if len(access[0].Actions) != 1 || access[0].Actions[0] != "pull" {
218 t.Errorf("Expected first actions [pull], got %v", access[0].Actions)
219 }
220
221 // Verify second entry
222 if access[1].Name != "alice/app2" {
223 t.Errorf("Expected second name %q, got %q", "alice/app2", access[1].Name)
224 }
225 if len(access[1].Actions) != 1 || access[1].Actions[0] != "push" {
226 t.Errorf("Expected second actions [push], got %v", access[1].Actions)
227 }
228
229 // Verify third entry
230 if access[2].Name != "bob/app3" {
231 t.Errorf("Expected third name %q, got %q", "bob/app3", access[2].Name)
232 }
233 if len(access[2].Actions) != 2 {
234 t.Errorf("Expected third entry to have 2 actions, got %d", len(access[2].Actions))
235 }
236}
237
238func TestValidateAccess_Owner(t *testing.T) {
239 userDID := "did:plc:alice123"
240 userHandle := "alice.bsky.social"
241
242 tests := []struct {
243 name string
244 repoName string
245 actions []string
246 shouldErr bool
247 errorMsg string
248 }{
249 {
250 name: "owner can push to own repo (by handle)",
251 repoName: "alice.bsky.social/myapp",
252 actions: []string{"push"},
253 shouldErr: false,
254 },
255 {
256 name: "owner can push to own repo (by DID)",
257 repoName: "did:plc:alice123/myapp",
258 actions: []string{"push"},
259 shouldErr: false,
260 },
261 {
262 name: "owner cannot push to others repo",
263 repoName: "bob.bsky.social/myapp",
264 actions: []string{"push"},
265 shouldErr: true,
266 errorMsg: "cannot push",
267 },
268 {
269 name: "wildcard scope allowed",
270 repoName: "*",
271 actions: []string{"push", "pull"},
272 shouldErr: false,
273 },
274 {
275 name: "owner can pull from others repo",
276 repoName: "bob.bsky.social/myapp",
277 actions: []string{"pull"},
278 shouldErr: false,
279 },
280 {
281 name: "owner cannot delete others repo",
282 repoName: "bob.bsky.social/myapp",
283 actions: []string{"delete"},
284 shouldErr: true,
285 errorMsg: "cannot delete",
286 },
287 {
288 name: "multiple actions with push fails for others",
289 repoName: "bob.bsky.social/myapp",
290 actions: []string{"pull", "push"},
291 shouldErr: true,
292 },
293 {
294 name: "empty repository name",
295 repoName: "",
296 actions: []string{"push"},
297 shouldErr: true,
298 },
299 }
300
301 for _, tt := range tests {
302 t.Run(tt.name, func(t *testing.T) {
303 access := []AccessEntry{
304 {
305 Type: "repository",
306 Name: tt.repoName,
307 Actions: tt.actions,
308 },
309 }
310
311 err := ValidateAccess(userDID, userHandle, access)
312 if tt.shouldErr && err == nil {
313 t.Error("Expected error but got none")
314 }
315 if !tt.shouldErr && err != nil {
316 t.Errorf("Expected no error but got: %v", err)
317 }
318 if tt.shouldErr && err != nil && tt.errorMsg != "" {
319 if !strings.Contains(err.Error(), tt.errorMsg) {
320 t.Errorf("Expected error to contain %q, got: %v", tt.errorMsg, err)
321 }
322 }
323 })
324 }
325}
326
327func TestValidateAccess_NonRepositoryType(t *testing.T) {
328 userDID := "did:plc:alice123"
329 userHandle := "alice.bsky.social"
330
331 // Non-repository types should be ignored
332 access := []AccessEntry{
333 {
334 Type: "registry",
335 Name: "something",
336 Actions: []string{"admin"},
337 },
338 }
339
340 err := ValidateAccess(userDID, userHandle, access)
341 if err != nil {
342 t.Errorf("Expected non-repository types to be ignored, got error: %v", err)
343 }
344}
345
346func TestValidateAccess_EmptyAccess(t *testing.T) {
347 userDID := "did:plc:alice123"
348 userHandle := "alice.bsky.social"
349
350 err := ValidateAccess(userDID, userHandle, nil)
351 if err != nil {
352 t.Errorf("Expected no error for empty access, got: %v", err)
353 }
354
355 err = ValidateAccess(userDID, userHandle, []AccessEntry{})
356 if err != nil {
357 t.Errorf("Expected no error for empty access slice, got: %v", err)
358 }
359}
360
361func TestValidateAccess_InvalidRepositoryName(t *testing.T) {
362 userDID := "did:plc:alice123"
363 userHandle := "alice.bsky.social"
364
365 // Repository name without slash - invalid format
366 access := []AccessEntry{
367 {
368 Type: "repository",
369 Name: "justareponame",
370 Actions: []string{"push"},
371 },
372 }
373
374 err := ValidateAccess(userDID, userHandle, access)
375 if err != nil {
376 // Should fail because can't extract owner from name without slash
377 // and it's not "*", so it will try to access [0] which is the whole string
378 // This is expected behavior - validate that owner check happens
379 t.Logf("Got expected validation error: %v", err)
380 }
381}
382
383func TestValidateAccess_DIDAndHandleBothWork(t *testing.T) {
384 userDID := "did:plc:alice123"
385 userHandle := "alice.bsky.social"
386
387 // Test with handle as owner
388 accessByHandle := []AccessEntry{
389 {
390 Type: "repository",
391 Name: "alice.bsky.social/myapp",
392 Actions: []string{"push"},
393 },
394 }
395
396 err := ValidateAccess(userDID, userHandle, accessByHandle)
397 if err != nil {
398 t.Errorf("Expected no error for handle match, got: %v", err)
399 }
400
401 // Test with DID as owner
402 accessByDID := []AccessEntry{
403 {
404 Type: "repository",
405 Name: "did:plc:alice123/myapp",
406 Actions: []string{"push"},
407 },
408 }
409
410 err = ValidateAccess(userDID, userHandle, accessByDID)
411 if err != nil {
412 t.Errorf("Expected no error for DID match, got: %v", err)
413 }
414}
415
416func TestValidateAccess_MixedActionsAndOwnership(t *testing.T) {
417 userDID := "did:plc:alice123"
418 userHandle := "alice.bsky.social"
419
420 // Mix of own and others' repositories
421 access := []AccessEntry{
422 {
423 Type: "repository",
424 Name: "alice.bsky.social/myapp",
425 Actions: []string{"push", "pull"},
426 },
427 {
428 Type: "repository",
429 Name: "bob.bsky.social/bobapp",
430 Actions: []string{"pull"}, // OK - just pull
431 },
432 }
433
434 err := ValidateAccess(userDID, userHandle, access)
435 if err != nil {
436 t.Errorf("Expected no error for valid mixed access, got: %v", err)
437 }
438
439 // Now add push to someone else's repo - should fail
440 access = []AccessEntry{
441 {
442 Type: "repository",
443 Name: "alice.bsky.social/myapp",
444 Actions: []string{"push"},
445 },
446 {
447 Type: "repository",
448 Name: "bob.bsky.social/bobapp",
449 Actions: []string{"push"}, // FAIL - can't push to others
450 },
451 }
452
453 err = ValidateAccess(userDID, userHandle, access)
454 if err == nil {
455 t.Error("Expected error when trying to push to others' repository")
456 }
457}
458
459func TestParseScope_EmptyActionsArray(t *testing.T) {
460 // Test with empty actions (colon present but no actions after it)
461 access, err := ParseScope([]string{"repository:alice/myapp:"})
462 if err != nil {
463 t.Fatalf("ParseScope() error = %v", err)
464 }
465
466 if len(access) != 1 {
467 t.Fatalf("Expected 1 entry, got %d", len(access))
468 }
469
470 // Actions should be nil or empty when actions string is empty
471 if len(access[0].Actions) > 0 {
472 t.Errorf("Expected nil or empty actions, got %v", access[0].Actions)
473 }
474}
475
476func TestParseScope_NilInput(t *testing.T) {
477 access, err := ParseScope(nil)
478 if err != nil {
479 t.Fatalf("ParseScope() with nil input error = %v", err)
480 }
481
482 if len(access) != 0 {
483 t.Errorf("Expected empty access for nil input, got %d entries", len(access))
484 }
485}