1package domain
2
3import (
4 "testing"
5
6 "github.com/stretchr/testify/assert"
7 "github.com/stretchr/testify/require"
8)
9
10func TestParseScope(t *testing.T) {
11 tests := []struct {
12 name string
13 input string
14 wantScope *Scope
15 wantErr bool
16 errContains string
17 }{
18 // Valid cases
19 {
20 name: "simple repository scope",
21 input: "repository:myrepo:pull",
22 wantScope: &Scope{
23 Type: "repository",
24 Name: "myrepo",
25 Actions: []string{"pull"},
26 },
27 },
28 {
29 name: "multiple actions",
30 input: "repository:myrepo:push,pull",
31 wantScope: &Scope{
32 Type: "repository",
33 Name: "myrepo",
34 Actions: []string{"push", "pull"},
35 },
36 },
37 {
38 name: "wildcard repository",
39 input: "repository:*:pull",
40 wantScope: &Scope{
41 Type: "repository",
42 Name: "*",
43 Actions: []string{"pull"},
44 },
45 },
46 {
47 name: "org wildcard",
48 input: "repository:myorg/*:push,pull",
49 wantScope: &Scope{
50 Type: "repository",
51 Name: "myorg/*",
52 Actions: []string{"push", "pull"},
53 },
54 },
55 {
56 name: "wildcard action",
57 input: "repository:myrepo:*",
58 wantScope: &Scope{
59 Type: "repository",
60 Name: "myrepo",
61 Actions: []string{"*"},
62 },
63 },
64 {
65 name: "nested repository name",
66 input: "repository:myorg/team/app:pull",
67 wantScope: &Scope{
68 Type: "repository",
69 Name: "myorg/team/app",
70 Actions: []string{"pull"},
71 },
72 },
73 {
74 name: "actions with whitespace trimmed",
75 input: "repository:myrepo:push, pull",
76 wantScope: &Scope{
77 Type: "repository",
78 Name: "myrepo",
79 Actions: []string{"push", "pull"},
80 },
81 },
82 {
83 name: "registry scope type",
84 input: "registry:catalog:*",
85 wantScope: &Scope{
86 Type: "registry",
87 Name: "catalog",
88 Actions: []string{"*"},
89 },
90 },
91
92 // Invalid cases
93 {
94 name: "empty string",
95 input: "",
96 wantErr: true,
97 errContains: "invalid scope format",
98 },
99 {
100 name: "missing actions",
101 input: "repository:myrepo",
102 wantErr: true,
103 errContains: "invalid scope format",
104 },
105 {
106 name: "missing name and actions",
107 input: "repository",
108 wantErr: true,
109 errContains: "invalid scope format",
110 },
111 {
112 name: "only type and name",
113 input: "repository:myrepo:",
114 wantScope: &Scope{
115 Type: "repository",
116 Name: "myrepo",
117 Actions: []string{""},
118 },
119 },
120 }
121
122 for _, tt := range tests {
123 t.Run(tt.name, func(t *testing.T) {
124 scope, err := ParseScope(tt.input)
125
126 if tt.wantErr {
127 require.Error(t, err)
128 if tt.errContains != "" {
129 assert.Contains(t, err.Error(), tt.errContains)
130 }
131 return
132 }
133
134 require.NoError(t, err)
135 assert.Equal(t, tt.wantScope.Type, scope.Type)
136 assert.Equal(t, tt.wantScope.Name, scope.Name)
137 assert.Equal(t, tt.wantScope.Actions, scope.Actions)
138 })
139 }
140}
141
142func TestScope_CanAccess(t *testing.T) {
143 tests := []struct {
144 name string
145 scope *Scope
146 repoName string
147 action string
148 want bool
149 }{
150 // Exact match cases
151 {
152 name: "exact repo match with pull",
153 scope: &Scope{Type: "repository", Name: "myrepo", Actions: []string{"pull"}},
154 repoName: "myrepo",
155 action: "pull",
156 want: true,
157 },
158 {
159 name: "exact repo match with push",
160 scope: &Scope{Type: "repository", Name: "myrepo", Actions: []string{"push"}},
161 repoName: "myrepo",
162 action: "push",
163 want: true,
164 },
165 {
166 name: "exact repo match wrong action",
167 scope: &Scope{Type: "repository", Name: "myrepo", Actions: []string{"pull"}},
168 repoName: "myrepo",
169 action: "push",
170 want: false,
171 },
172 {
173 name: "wrong repo name",
174 scope: &Scope{Type: "repository", Name: "myrepo", Actions: []string{"pull"}},
175 repoName: "otherrepo",
176 action: "pull",
177 want: false,
178 },
179
180 // Wildcard repository cases
181 {
182 name: "wildcard repo matches any",
183 scope: &Scope{Type: "repository", Name: "*", Actions: []string{"pull"}},
184 repoName: "anyrepo",
185 action: "pull",
186 want: true,
187 },
188 {
189 name: "wildcard repo matches nested",
190 scope: &Scope{Type: "repository", Name: "*", Actions: []string{"pull"}},
191 repoName: "myorg/myapp",
192 action: "pull",
193 want: true,
194 },
195
196 // Org-level wildcard cases
197 {
198 name: "org wildcard matches repo in org",
199 scope: &Scope{Type: "repository", Name: "myorg/*", Actions: []string{"pull"}},
200 repoName: "myorg/myapp",
201 action: "pull",
202 want: true,
203 },
204 {
205 name: "org wildcard matches nested repo in org",
206 scope: &Scope{Type: "repository", Name: "myorg/*", Actions: []string{"pull"}},
207 repoName: "myorg/team/myapp",
208 action: "pull",
209 want: true,
210 },
211 {
212 name: "org wildcard does not match other org",
213 scope: &Scope{Type: "repository", Name: "myorg/*", Actions: []string{"pull"}},
214 repoName: "otherorg/myapp",
215 action: "pull",
216 want: false,
217 },
218 {
219 name: "org wildcard does not match repo without slash",
220 scope: &Scope{Type: "repository", Name: "myorg/*", Actions: []string{"pull"}},
221 repoName: "myorg",
222 action: "pull",
223 want: false,
224 },
225
226 // Wildcard action cases
227 {
228 name: "wildcard action grants pull",
229 scope: &Scope{Type: "repository", Name: "myrepo", Actions: []string{"*"}},
230 repoName: "myrepo",
231 action: "pull",
232 want: true,
233 },
234 {
235 name: "wildcard action grants push",
236 scope: &Scope{Type: "repository", Name: "myrepo", Actions: []string{"*"}},
237 repoName: "myrepo",
238 action: "push",
239 want: true,
240 },
241
242 // Multiple actions
243 {
244 name: "multiple actions includes requested",
245 scope: &Scope{Type: "repository", Name: "myrepo", Actions: []string{"push", "pull"}},
246 repoName: "myrepo",
247 action: "pull",
248 want: true,
249 },
250 {
251 name: "multiple actions includes push",
252 scope: &Scope{Type: "repository", Name: "myrepo", Actions: []string{"push", "pull"}},
253 repoName: "myrepo",
254 action: "push",
255 want: true,
256 },
257
258 // Non-repository type
259 {
260 name: "registry type does not grant repo access",
261 scope: &Scope{Type: "registry", Name: "catalog", Actions: []string{"*"}},
262 repoName: "myrepo",
263 action: "pull",
264 want: false,
265 },
266
267 // Edge cases
268 {
269 name: "empty actions",
270 scope: &Scope{Type: "repository", Name: "myrepo", Actions: []string{}},
271 repoName: "myrepo",
272 action: "pull",
273 want: false,
274 },
275 {
276 name: "empty repo name in scope",
277 scope: &Scope{Type: "repository", Name: "", Actions: []string{"pull"}},
278 repoName: "myrepo",
279 action: "pull",
280 want: false,
281 },
282 }
283
284 for _, tt := range tests {
285 t.Run(tt.name, func(t *testing.T) {
286 got := tt.scope.CanAccess(tt.repoName, tt.action)
287 assert.Equal(t, tt.want, got)
288 })
289 }
290}
291
292func TestScope_String(t *testing.T) {
293 tests := []struct {
294 name string
295 scope *Scope
296 want string
297 }{
298 {
299 name: "simple scope",
300 scope: &Scope{Type: "repository", Name: "myrepo", Actions: []string{"pull"}},
301 want: "repository:myrepo:pull",
302 },
303 {
304 name: "multiple actions",
305 scope: &Scope{Type: "repository", Name: "myrepo", Actions: []string{"push", "pull"}},
306 want: "repository:myrepo:push,pull",
307 },
308 {
309 name: "wildcard",
310 scope: &Scope{Type: "repository", Name: "*", Actions: []string{"*"}},
311 want: "repository:*:*",
312 },
313 {
314 name: "org wildcard",
315 scope: &Scope{Type: "repository", Name: "myorg/*", Actions: []string{"push", "pull"}},
316 want: "repository:myorg/*:push,pull",
317 },
318 }
319
320 for _, tt := range tests {
321 t.Run(tt.name, func(t *testing.T) {
322 got := tt.scope.String()
323 assert.Equal(t, tt.want, got)
324 })
325 }
326}
327
328func TestScopesGrantRegistryAccess(t *testing.T) {
329 tests := []struct {
330 name string
331 granted []string
332 repoName string
333 action string
334 want bool
335 }{
336 {
337 name: "wildcard shorthand grants any action",
338 granted: []string{"*"},
339 repoName: "myrepo",
340 action: "push",
341 want: true,
342 },
343 {
344 name: "pull shorthand grants pull",
345 granted: []string{"pull"},
346 repoName: "myrepo",
347 action: "pull",
348 want: true,
349 },
350 {
351 name: "pull shorthand does not grant push",
352 granted: []string{"pull"},
353 repoName: "myrepo",
354 action: "push",
355 want: false,
356 },
357 {
358 name: "push shorthand grants push",
359 granted: []string{"push"},
360 repoName: "myrepo",
361 action: "push",
362 want: true,
363 },
364 {
365 name: "full scope grants matching repo and action",
366 granted: []string{"repository:myrepo:push,pull"},
367 repoName: "myrepo",
368 action: "pull",
369 want: true,
370 },
371 {
372 name: "full scope does not grant different repo",
373 granted: []string{"repository:myrepo:push,pull"},
374 repoName: "other",
375 action: "pull",
376 want: false,
377 },
378 {
379 name: "wildcard repo scope grants any repo",
380 granted: []string{"repository:*:pull"},
381 repoName: "anything",
382 action: "pull",
383 want: true,
384 },
385 {
386 name: "org wildcard scope grants repo in org",
387 granted: []string{"repository:myorg/*:push"},
388 repoName: "myorg/app",
389 action: "push",
390 want: true,
391 },
392 {
393 name: "org wildcard scope rejects repo outside org",
394 granted: []string{"repository:myorg/*:push"},
395 repoName: "other/app",
396 action: "push",
397 want: false,
398 },
399 {
400 name: "admin scope is ignored for registry access",
401 granted: []string{"admin:routes:read"},
402 repoName: "myrepo",
403 action: "pull",
404 want: false,
405 },
406 {
407 name: "no granted scopes",
408 granted: []string{},
409 repoName: "myrepo",
410 action: "pull",
411 want: false,
412 },
413 {
414 name: "whitespace around scope is trimmed",
415 granted: []string{" repository:myrepo:pull "},
416 repoName: "myrepo",
417 action: "pull",
418 want: true,
419 },
420 {
421 name: "invalid scope string is skipped",
422 granted: []string{"garbage", "repository:myrepo:pull"},
423 repoName: "myrepo",
424 action: "pull",
425 want: true,
426 },
427 }
428
429 for _, tt := range tests {
430 t.Run(tt.name, func(t *testing.T) {
431 got := ScopesGrantRegistryAccess(tt.granted, tt.repoName, tt.action)
432 assert.Equal(t, tt.want, got)
433 })
434 }
435}
436
437func TestScopesGrantAdminAccess(t *testing.T) {
438 tests := []struct {
439 name string
440 granted []string
441 resource string
442 action string
443 want bool
444 }{
445 {
446 name: "wildcard shorthand grants any admin access",
447 granted: []string{"*"},
448 resource: "routes",
449 action: "read",
450 want: true,
451 },
452 {
453 name: "exact admin scope grants matching resource and action",
454 granted: []string{"admin:routes:read"},
455 resource: "routes",
456 action: "read",
457 want: true,
458 },
459 {
460 name: "exact admin scope denies wrong action",
461 granted: []string{"admin:routes:read"},
462 resource: "routes",
463 action: "write",
464 want: false,
465 },
466 {
467 name: "exact admin scope denies wrong resource",
468 granted: []string{"admin:routes:read"},
469 resource: "secrets",
470 action: "read",
471 want: false,
472 },
473 {
474 name: "admin wildcard resource grants any resource",
475 granted: []string{"admin:*:read"},
476 resource: "secrets",
477 action: "read",
478 want: true,
479 },
480 {
481 name: "admin wildcard action grants any action",
482 granted: []string{"admin:routes:*"},
483 resource: "routes",
484 action: "write",
485 want: true,
486 },
487 {
488 name: "full admin wildcard grants everything",
489 granted: []string{"admin:*:*"},
490 resource: "config",
491 action: "write",
492 want: true,
493 },
494 {
495 name: "logs read grants logs resource",
496 granted: []string{"admin:logs:read"},
497 resource: AdminResourceLogs,
498 action: AdminActionRead,
499 want: true,
500 },
501 {
502 name: "status read does not grant logs resource",
503 granted: []string{"admin:status:read"},
504 resource: AdminResourceLogs,
505 action: AdminActionRead,
506 want: false,
507 },
508 {
509 name: "full admin wildcard grants logs resource",
510 granted: []string{"admin:*:*"},
511 resource: AdminResourceLogs,
512 action: AdminActionRead,
513 want: true,
514 },
515 {
516 name: "volumes read grants volumes resource",
517 granted: []string{"admin:volumes:read"},
518 resource: AdminResourceVolumes,
519 action: AdminActionRead,
520 want: true,
521 },
522 {
523 name: "repository scope is ignored for admin access",
524 granted: []string{"repository:myrepo:push"},
525 resource: "routes",
526 action: "read",
527 want: false,
528 },
529 {
530 name: "no granted scopes",
531 granted: []string{},
532 resource: "routes",
533 action: "read",
534 want: false,
535 },
536 {
537 name: "whitespace around scope is trimmed",
538 granted: []string{" admin:routes:read "},
539 resource: "routes",
540 action: "read",
541 want: true,
542 },
543 }
544
545 for _, tt := range tests {
546 t.Run(tt.name, func(t *testing.T) {
547 got := ScopesGrantAdminAccess(tt.granted, tt.resource, tt.action)
548 assert.Equal(t, tt.want, got)
549 })
550 }
551}
552
553func TestParseScope_RoundTrip(t *testing.T) {
554 // Test that parsing and stringifying produces the same result
555 inputs := []string{
556 "repository:myrepo:pull",
557 "repository:myrepo:push,pull",
558 "repository:*:*",
559 "repository:myorg/*:push,pull",
560 "repository:myorg/team/app:pull",
561 }
562
563 for _, input := range inputs {
564 t.Run(input, func(t *testing.T) {
565 scope, err := ParseScope(input)
566 require.NoError(t, err)
567
568 output := scope.String()
569 assert.Equal(t, input, output)
570 })
571 }
572}