detached 04d8163

test(security): add tests and improvements per code review

- Add comprehensive tests for Scope type (ParseScope, CanAccess, String)
- Add tests for checkScopeAccess middleware function
- Add tests for parseRequestedScopes token handler function
- Fix fragile error string comparison using errors.As(*http.MaxBytesError)
- Add clarifying comment for token claims context storage
- Improve reference regex comment for case-sensitivity
commit
04d81634a30f0359d6f835bb19db8ddfb88a04c3
author
bnema · · 2026-01-14 21:36 UTC
parent
0a0361f
signature
unsigned
6 files · +774 -5
M internal/adapters/in/http/middleware/auth.go +2 -1
@@ -155,7 +155,8 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
155155 sendForbidden(w, log, r)
156156 return
157157 }
158- // Store claims in context for downstream use
158+ // Store claims in context for downstream handlers that need access to token metadata
159+ // (e.g., audit logging, rate limiting by subject, or future per-user quotas)
159160 ctx = context.WithValue(ctx, TokenClaimsKey, tokenClaims)
160161 r = r.WithContext(ctx)
161162 }
@@ -155,7 +155,8 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
155 sendForbidden(w, log, r)155 sendForbidden(w, log, r)
156 return156 return
157 }157 }
158- // Store claims in context for downstream use158+ // Store claims in context for downstream handlers that need access to token metadata
159+ // (e.g., audit logging, rate limiting by subject, or future per-user quotas)
159 ctx = context.WithValue(ctx, TokenClaimsKey, tokenClaims)160 ctx = context.WithValue(ctx, TokenClaimsKey, tokenClaims)
160 r = r.WithContext(ctx)161 r = r.WithContext(ctx)
161 }162 }
M internal/adapters/in/http/middleware/auth_test.go +225 -0
@@ -492,3 +492,228 @@ func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) {
492492 func (s stubAuthService) GeneratePasswordHash(string) (string, error) {
493493 return "", errors.New("not implemented")
494494 }
495+
496+// Tests for checkScopeAccess function
497+
498+func TestCheckScopeAccess_ActionMapping(t *testing.T) {
499+ log := testLogger()
500+
501+ tests := []struct {
502+ name string
503+ method string
504+ expectedAction string
505+ }{
506+ {"GET maps to pull", http.MethodGet, "pull"},
507+ {"HEAD maps to pull", http.MethodHead, "pull"},
508+ {"PUT maps to push", http.MethodPut, "push"},
509+ {"POST maps to push", http.MethodPost, "push"},
510+ {"PATCH maps to push", http.MethodPatch, "push"},
511+ {"DELETE maps to push", http.MethodDelete, "push"},
512+ {"OPTIONS defaults to pull", http.MethodOptions, "pull"},
513+ }
514+
515+ for _, tt := range tests {
516+ t.Run(tt.name, func(t *testing.T) {
517+ claims := &domain.TokenClaims{
518+ Scopes: []string{"repository:myrepo:" + tt.expectedAction},
519+ }
520+
521+ req := httptest.NewRequest(tt.method, "/v2/myrepo/manifests/latest", nil)
522+ result := checkScopeAccess(req, claims, log)
523+
524+ assert.True(t, result, "method %s should be allowed with %s scope", tt.method, tt.expectedAction)
525+ })
526+ }
527+}
528+
529+func TestCheckScopeAccess_RepoNameExtraction(t *testing.T) {
530+ log := testLogger()
531+
532+ tests := []struct {
533+ name string
534+ path string
535+ wantRepo string
536+ scopes []string
537+ want bool
538+ }{
539+ {
540+ name: "simple repo with manifests",
541+ path: "/v2/myrepo/manifests/latest",
542+ scopes: []string{"repository:myrepo:pull"},
543+ want: true,
544+ },
545+ {
546+ name: "simple repo with blobs",
547+ path: "/v2/myrepo/blobs/sha256:abc123",
548+ scopes: []string{"repository:myrepo:pull"},
549+ want: true,
550+ },
551+ {
552+ name: "nested repo with manifests",
553+ path: "/v2/myorg/myapp/manifests/v1.0",
554+ scopes: []string{"repository:myorg/myapp:pull"},
555+ want: true,
556+ },
557+ {
558+ name: "deeply nested repo",
559+ path: "/v2/myorg/team/myapp/manifests/latest",
560+ scopes: []string{"repository:myorg/team/myapp:pull"},
561+ want: true,
562+ },
563+ {
564+ name: "repo with tags list",
565+ path: "/v2/myrepo/tags/list",
566+ scopes: []string{"repository:myrepo:pull"},
567+ want: true,
568+ },
569+ {
570+ name: "wrong repo name",
571+ path: "/v2/myrepo/manifests/latest",
572+ scopes: []string{"repository:otherrepo:pull"},
573+ want: false,
574+ },
575+ }
576+
577+ for _, tt := range tests {
578+ t.Run(tt.name, func(t *testing.T) {
579+ claims := &domain.TokenClaims{Scopes: tt.scopes}
580+ req := httptest.NewRequest(http.MethodGet, tt.path, nil)
581+ result := checkScopeAccess(req, claims, log)
582+ assert.Equal(t, tt.want, result)
583+ })
584+ }
585+}
586+
587+func TestCheckScopeAccess_WildcardMatching(t *testing.T) {
588+ log := testLogger()
589+
590+ tests := []struct {
591+ name string
592+ path string
593+ scopes []string
594+ want bool
595+ }{
596+ {
597+ name: "wildcard repo matches any",
598+ path: "/v2/anyrepo/manifests/latest",
599+ scopes: []string{"repository:*:pull"},
600+ want: true,
601+ },
602+ {
603+ name: "org wildcard matches repo in org",
604+ path: "/v2/myorg/myapp/manifests/latest",
605+ scopes: []string{"repository:myorg/*:pull"},
606+ want: true,
607+ },
608+ {
609+ name: "org wildcard does not match other org",
610+ path: "/v2/otherorg/myapp/manifests/latest",
611+ scopes: []string{"repository:myorg/*:pull"},
612+ want: false,
613+ },
614+ {
615+ name: "wildcard action grants push",
616+ path: "/v2/myrepo/manifests/latest",
617+ scopes: []string{"repository:myrepo:*"},
618+ want: true,
619+ },
620+ }
621+
622+ for _, tt := range tests {
623+ t.Run(tt.name, func(t *testing.T) {
624+ claims := &domain.TokenClaims{Scopes: tt.scopes}
625+ req := httptest.NewRequest(http.MethodGet, tt.path, nil)
626+ result := checkScopeAccess(req, claims, log)
627+ assert.Equal(t, tt.want, result)
628+ })
629+ }
630+}
631+
632+func TestCheckScopeAccess_SpecialRoutes(t *testing.T) {
633+ log := testLogger()
634+
635+ tests := []struct {
636+ name string
637+ path string
638+ scopes []string
639+ want bool
640+ }{
641+ {
642+ name: "v2 root is allowed",
643+ path: "/v2/",
644+ scopes: []string{"repository:myrepo:pull"},
645+ want: true,
646+ },
647+ {
648+ name: "non-v2 path is allowed",
649+ path: "/healthz",
650+ scopes: []string{"repository:myrepo:pull"},
651+ want: true,
652+ },
653+ {
654+ name: "catalog path is handled",
655+ path: "/v2/_catalog",
656+ scopes: []string{"repository:myrepo:pull"},
657+ want: true,
658+ },
659+ }
660+
661+ for _, tt := range tests {
662+ t.Run(tt.name, func(t *testing.T) {
663+ claims := &domain.TokenClaims{Scopes: tt.scopes}
664+ req := httptest.NewRequest(http.MethodGet, tt.path, nil)
665+ result := checkScopeAccess(req, claims, log)
666+ assert.Equal(t, tt.want, result)
667+ })
668+ }
669+}
670+
671+func TestCheckScopeAccess_DenialCases(t *testing.T) {
672+ log := testLogger()
673+
674+ tests := []struct {
675+ name string
676+ method string
677+ path string
678+ scopes []string
679+ want bool
680+ }{
681+ {
682+ name: "pull scope denies push",
683+ method: http.MethodPut,
684+ path: "/v2/myrepo/manifests/latest",
685+ scopes: []string{"repository:myrepo:pull"},
686+ want: false,
687+ },
688+ {
689+ name: "wrong repo denies access",
690+ method: http.MethodGet,
691+ path: "/v2/myrepo/manifests/latest",
692+ scopes: []string{"repository:otherrepo:pull"},
693+ want: false,
694+ },
695+ {
696+ name: "empty scopes denies access",
697+ method: http.MethodGet,
698+ path: "/v2/myrepo/manifests/latest",
699+ scopes: []string{},
700+ want: false,
701+ },
702+ {
703+ name: "invalid scope format is skipped",
704+ method: http.MethodGet,
705+ path: "/v2/myrepo/manifests/latest",
706+ scopes: []string{"invalid-scope-format"},
707+ want: false,
708+ },
709+ }
710+
711+ for _, tt := range tests {
712+ t.Run(tt.name, func(t *testing.T) {
713+ claims := &domain.TokenClaims{Scopes: tt.scopes}
714+ req := httptest.NewRequest(tt.method, tt.path, nil)
715+ result := checkScopeAccess(req, claims, log)
716+ assert.Equal(t, tt.want, result)
717+ })
718+ }
719+}
@@ -492,3 +492,228 @@ func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) {
492 func (s stubAuthService) GeneratePasswordHash(string) (string, error) {492 func (s stubAuthService) GeneratePasswordHash(string) (string, error) {
493 return "", errors.New("not implemented")493 return "", errors.New("not implemented")
494 }494 }
495+
496+// Tests for checkScopeAccess function
497+
498+func TestCheckScopeAccess_ActionMapping(t *testing.T) {
499+ log := testLogger()
500+
501+ tests := []struct {
502+ name string
503+ method string
504+ expectedAction string
505+ }{
506+ {"GET maps to pull", http.MethodGet, "pull"},
507+ {"HEAD maps to pull", http.MethodHead, "pull"},
508+ {"PUT maps to push", http.MethodPut, "push"},
509+ {"POST maps to push", http.MethodPost, "push"},
510+ {"PATCH maps to push", http.MethodPatch, "push"},
511+ {"DELETE maps to push", http.MethodDelete, "push"},
512+ {"OPTIONS defaults to pull", http.MethodOptions, "pull"},
513+ }
514+
515+ for _, tt := range tests {
516+ t.Run(tt.name, func(t *testing.T) {
517+ claims := &domain.TokenClaims{
518+ Scopes: []string{"repository:myrepo:" + tt.expectedAction},
519+ }
520+
521+ req := httptest.NewRequest(tt.method, "/v2/myrepo/manifests/latest", nil)
522+ result := checkScopeAccess(req, claims, log)
523+
524+ assert.True(t, result, "method %s should be allowed with %s scope", tt.method, tt.expectedAction)
525+ })
526+ }
527+}
528+
529+func TestCheckScopeAccess_RepoNameExtraction(t *testing.T) {
530+ log := testLogger()
531+
532+ tests := []struct {
533+ name string
534+ path string
535+ wantRepo string
536+ scopes []string
537+ want bool
538+ }{
539+ {
540+ name: "simple repo with manifests",
541+ path: "/v2/myrepo/manifests/latest",
542+ scopes: []string{"repository:myrepo:pull"},
543+ want: true,
544+ },
545+ {
546+ name: "simple repo with blobs",
547+ path: "/v2/myrepo/blobs/sha256:abc123",
548+ scopes: []string{"repository:myrepo:pull"},
549+ want: true,
550+ },
551+ {
552+ name: "nested repo with manifests",
553+ path: "/v2/myorg/myapp/manifests/v1.0",
554+ scopes: []string{"repository:myorg/myapp:pull"},
555+ want: true,
556+ },
557+ {
558+ name: "deeply nested repo",
559+ path: "/v2/myorg/team/myapp/manifests/latest",
560+ scopes: []string{"repository:myorg/team/myapp:pull"},
561+ want: true,
562+ },
563+ {
564+ name: "repo with tags list",
565+ path: "/v2/myrepo/tags/list",
566+ scopes: []string{"repository:myrepo:pull"},
567+ want: true,
568+ },
569+ {
570+ name: "wrong repo name",
571+ path: "/v2/myrepo/manifests/latest",
572+ scopes: []string{"repository:otherrepo:pull"},
573+ want: false,
574+ },
575+ }
576+
577+ for _, tt := range tests {
578+ t.Run(tt.name, func(t *testing.T) {
579+ claims := &domain.TokenClaims{Scopes: tt.scopes}
580+ req := httptest.NewRequest(http.MethodGet, tt.path, nil)
581+ result := checkScopeAccess(req, claims, log)
582+ assert.Equal(t, tt.want, result)
583+ })
584+ }
585+}
586+
587+func TestCheckScopeAccess_WildcardMatching(t *testing.T) {
588+ log := testLogger()
589+
590+ tests := []struct {
591+ name string
592+ path string
593+ scopes []string
594+ want bool
595+ }{
596+ {
597+ name: "wildcard repo matches any",
598+ path: "/v2/anyrepo/manifests/latest",
599+ scopes: []string{"repository:*:pull"},
600+ want: true,
601+ },
602+ {
603+ name: "org wildcard matches repo in org",
604+ path: "/v2/myorg/myapp/manifests/latest",
605+ scopes: []string{"repository:myorg/*:pull"},
606+ want: true,
607+ },
608+ {
609+ name: "org wildcard does not match other org",
610+ path: "/v2/otherorg/myapp/manifests/latest",
611+ scopes: []string{"repository:myorg/*:pull"},
612+ want: false,
613+ },
614+ {
615+ name: "wildcard action grants push",
616+ path: "/v2/myrepo/manifests/latest",
617+ scopes: []string{"repository:myrepo:*"},
618+ want: true,
619+ },
620+ }
621+
622+ for _, tt := range tests {
623+ t.Run(tt.name, func(t *testing.T) {
624+ claims := &domain.TokenClaims{Scopes: tt.scopes}
625+ req := httptest.NewRequest(http.MethodGet, tt.path, nil)
626+ result := checkScopeAccess(req, claims, log)
627+ assert.Equal(t, tt.want, result)
628+ })
629+ }
630+}
631+
632+func TestCheckScopeAccess_SpecialRoutes(t *testing.T) {
633+ log := testLogger()
634+
635+ tests := []struct {
636+ name string
637+ path string
638+ scopes []string
639+ want bool
640+ }{
641+ {
642+ name: "v2 root is allowed",
643+ path: "/v2/",
644+ scopes: []string{"repository:myrepo:pull"},
645+ want: true,
646+ },
647+ {
648+ name: "non-v2 path is allowed",
649+ path: "/healthz",
650+ scopes: []string{"repository:myrepo:pull"},
651+ want: true,
652+ },
653+ {
654+ name: "catalog path is handled",
655+ path: "/v2/_catalog",
656+ scopes: []string{"repository:myrepo:pull"},
657+ want: true,
658+ },
659+ }
660+
661+ for _, tt := range tests {
662+ t.Run(tt.name, func(t *testing.T) {
663+ claims := &domain.TokenClaims{Scopes: tt.scopes}
664+ req := httptest.NewRequest(http.MethodGet, tt.path, nil)
665+ result := checkScopeAccess(req, claims, log)
666+ assert.Equal(t, tt.want, result)
667+ })
668+ }
669+}
670+
671+func TestCheckScopeAccess_DenialCases(t *testing.T) {
672+ log := testLogger()
673+
674+ tests := []struct {
675+ name string
676+ method string
677+ path string
678+ scopes []string
679+ want bool
680+ }{
681+ {
682+ name: "pull scope denies push",
683+ method: http.MethodPut,
684+ path: "/v2/myrepo/manifests/latest",
685+ scopes: []string{"repository:myrepo:pull"},
686+ want: false,
687+ },
688+ {
689+ name: "wrong repo denies access",
690+ method: http.MethodGet,
691+ path: "/v2/myrepo/manifests/latest",
692+ scopes: []string{"repository:otherrepo:pull"},
693+ want: false,
694+ },
695+ {
696+ name: "empty scopes denies access",
697+ method: http.MethodGet,
698+ path: "/v2/myrepo/manifests/latest",
699+ scopes: []string{},
700+ want: false,
701+ },
702+ {
703+ name: "invalid scope format is skipped",
704+ method: http.MethodGet,
705+ path: "/v2/myrepo/manifests/latest",
706+ scopes: []string{"invalid-scope-format"},
707+ want: false,
708+ },
709+ }
710+
711+ for _, tt := range tests {
712+ t.Run(tt.name, func(t *testing.T) {
713+ claims := &domain.TokenClaims{Scopes: tt.scopes}
714+ req := httptest.NewRequest(tt.method, tt.path, nil)
715+ result := checkScopeAccess(req, claims, log)
716+ assert.Equal(t, tt.want, result)
717+ })
718+ }
719+}
M internal/adapters/in/http/registry/handler.go +5 -2
@@ -4,6 +4,7 @@ package registry
44 import (
55 "crypto/sha256"
66 "encoding/json"
7+ "errors"
78 "fmt"
89 "io"
910 "net/http"
@@ -303,7 +304,8 @@ func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
303304 // Read manifest data
304305 data, err := io.ReadAll(r.Body)
305306 if err != nil {
306- if err.Error() == "http: request body too large" {
307+ var maxBytesErr *http.MaxBytesError
308+ if errors.As(err, &maxBytesErr) {
307309 log.Warn().Int64("max_size", MaxManifestSize).Msg("manifest too large")
308310 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "manifest exceeds maximum size")
309311 return
@@ -408,7 +410,8 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
408410 // Read the chunk from the request body
409411 chunk, err := io.ReadAll(r.Body)
410412 if err != nil {
411- if err.Error() == "http: request body too large" {
413+ var maxBytesErr *http.MaxBytesError
414+ if errors.As(err, &maxBytesErr) {
412415 log.Warn().Int64("max_size", MaxBlobChunkSize).Msg("blob chunk too large")
413416 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size")
414417 return
@@ -4,6 +4,7 @@ package registry
4 import (4 import (
5 "crypto/sha256"5 "crypto/sha256"
6 "encoding/json"6 "encoding/json"
7+ "errors"
7 "fmt"8 "fmt"
8 "io"9 "io"
9 "net/http"10 "net/http"
@@ -303,7 +304,8 @@ func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
303 // Read manifest data304 // Read manifest data
304 data, err := io.ReadAll(r.Body)305 data, err := io.ReadAll(r.Body)
305 if err != nil {306 if err != nil {
306- if err.Error() == "http: request body too large" {307+ var maxBytesErr *http.MaxBytesError
308+ if errors.As(err, &maxBytesErr) {
307 log.Warn().Int64("max_size", MaxManifestSize).Msg("manifest too large")309 log.Warn().Int64("max_size", MaxManifestSize).Msg("manifest too large")
308 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "manifest exceeds maximum size")310 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "manifest exceeds maximum size")
309 return311 return
@@ -408,7 +410,8 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
408 // Read the chunk from the request body410 // Read the chunk from the request body
409 chunk, err := io.ReadAll(r.Body)411 chunk, err := io.ReadAll(r.Body)
410 if err != nil {412 if err != nil {
411- if err.Error() == "http: request body too large" {413+ var maxBytesErr *http.MaxBytesError
414+ if errors.As(err, &maxBytesErr) {
412 log.Warn().Int64("max_size", MaxBlobChunkSize).Msg("blob chunk too large")415 log.Warn().Int64("max_size", MaxBlobChunkSize).Msg("blob chunk too large")
413 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size")416 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size")
414 return417 return
A internal/adapters/in/http/registry/token_test.go +192 -0
@@ -0,0 +1,192 @@
1+package registry
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "testing"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+)
11+
12+func tokenTestLogger() zerowrap.Logger {
13+ return zerowrap.Default()
14+}
15+
16+func TestParseRequestedScopes_MultipleScopeParams(t *testing.T) {
17+ log := tokenTestLogger()
18+ h := &TokenHandler{log: log}
19+
20+ req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=repository:repo1:pull&scope=repository:repo2:push", nil)
21+
22+ scopes := h.parseRequestedScopes(req, log)
23+
24+ assert.Len(t, scopes, 2)
25+ assert.Contains(t, scopes, "repository:repo1:pull")
26+ assert.Contains(t, scopes, "repository:repo2:push")
27+}
28+
29+func TestParseRequestedScopes_InvalidScopeFiltered(t *testing.T) {
30+ log := tokenTestLogger()
31+ h := &TokenHandler{log: log}
32+
33+ tests := []struct {
34+ name string
35+ query string
36+ wantScopes []string
37+ wantDefault bool
38+ }{
39+ {
40+ name: "invalid format is skipped",
41+ query: "scope=invalid-no-colons&scope=repository:valid:pull",
42+ wantScopes: []string{"repository:valid:pull"},
43+ wantDefault: false,
44+ },
45+ {
46+ name: "missing actions is skipped",
47+ query: "scope=repository:myrepo&scope=repository:valid:pull",
48+ wantScopes: []string{"repository:valid:pull"},
49+ wantDefault: false,
50+ },
51+ {
52+ name: "all invalid returns default",
53+ query: "scope=invalid&scope=also-invalid",
54+ wantScopes: []string{"repository:*:pull"},
55+ wantDefault: true,
56+ },
57+ }
58+
59+ for _, tt := range tests {
60+ t.Run(tt.name, func(t *testing.T) {
61+ req := httptest.NewRequest(http.MethodGet, "/v2/token?"+tt.query, nil)
62+ scopes := h.parseRequestedScopes(req, log)
63+
64+ assert.Equal(t, tt.wantScopes, scopes)
65+ })
66+ }
67+}
68+
69+func TestParseRequestedScopes_NonRepositoryScopeRejected(t *testing.T) {
70+ log := tokenTestLogger()
71+ h := &TokenHandler{log: log}
72+
73+ tests := []struct {
74+ name string
75+ query string
76+ wantScopes []string
77+ }{
78+ {
79+ name: "registry scope is rejected",
80+ query: "scope=registry:catalog:*&scope=repository:myrepo:pull",
81+ wantScopes: []string{"repository:myrepo:pull"},
82+ },
83+ {
84+ name: "only non-repository scopes returns default",
85+ query: "scope=registry:catalog:*&scope=custom:something:action",
86+ wantScopes: []string{"repository:*:pull"},
87+ },
88+ }
89+
90+ for _, tt := range tests {
91+ t.Run(tt.name, func(t *testing.T) {
92+ req := httptest.NewRequest(http.MethodGet, "/v2/token?"+tt.query, nil)
93+ scopes := h.parseRequestedScopes(req, log)
94+
95+ assert.Equal(t, tt.wantScopes, scopes)
96+ })
97+ }
98+}
99+
100+func TestParseRequestedScopes_NoScopeReturnsDefault(t *testing.T) {
101+ log := tokenTestLogger()
102+ h := &TokenHandler{log: log}
103+
104+ req := httptest.NewRequest(http.MethodGet, "/v2/token", nil)
105+ scopes := h.parseRequestedScopes(req, log)
106+
107+ assert.Equal(t, []string{"repository:*:pull"}, scopes)
108+}
109+
110+func TestParseRequestedScopes_EmptyScopeParamReturnsDefault(t *testing.T) {
111+ log := tokenTestLogger()
112+ h := &TokenHandler{log: log}
113+
114+ req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=", nil)
115+ scopes := h.parseRequestedScopes(req, log)
116+
117+ // Empty scope param is invalid and should return default
118+ assert.Equal(t, []string{"repository:*:pull"}, scopes)
119+}
120+
121+func TestParseRequestedScopes_ValidScopesPreserved(t *testing.T) {
122+ log := tokenTestLogger()
123+ h := &TokenHandler{log: log}
124+
125+ tests := []struct {
126+ name string
127+ query string
128+ wantScopes []string
129+ }{
130+ {
131+ name: "single scope",
132+ query: "scope=repository:myrepo:pull",
133+ wantScopes: []string{"repository:myrepo:pull"},
134+ },
135+ {
136+ name: "multiple actions",
137+ query: "scope=repository:myrepo:push,pull",
138+ wantScopes: []string{"repository:myrepo:push,pull"},
139+ },
140+ {
141+ name: "wildcard repo",
142+ query: "scope=repository:*:pull",
143+ wantScopes: []string{"repository:*:pull"},
144+ },
145+ {
146+ name: "org wildcard",
147+ query: "scope=repository:myorg/*:push,pull",
148+ wantScopes: []string{"repository:myorg/*:push,pull"},
149+ },
150+ {
151+ name: "nested repo",
152+ query: "scope=repository:myorg/team/app:pull",
153+ wantScopes: []string{"repository:myorg/team/app:pull"},
154+ },
155+ }
156+
157+ for _, tt := range tests {
158+ t.Run(tt.name, func(t *testing.T) {
159+ req := httptest.NewRequest(http.MethodGet, "/v2/token?"+tt.query, nil)
160+ scopes := h.parseRequestedScopes(req, log)
161+
162+ assert.Equal(t, tt.wantScopes, scopes)
163+ })
164+ }
165+}
166+
167+func TestIsLocalhostRequest(t *testing.T) {
168+ tests := []struct {
169+ name string
170+ remoteAddr string
171+ want bool
172+ }{
173+ {"127.0.0.1 is localhost", "127.0.0.1:12345", true},
174+ {"127.0.0.2 is localhost", "127.0.0.2:12345", true},
175+ {"::1 is localhost", "[::1]:12345", true},
176+ {"::1 without brackets", "::1:12345", true},
177+ {"192.168.1.1 is not localhost", "192.168.1.1:12345", false},
178+ {"10.0.0.1 is not localhost", "10.0.0.1:12345", false},
179+ {"public IP is not localhost", "8.8.8.8:12345", false},
180+ }
181+
182+ for _, tt := range tests {
183+ t.Run(tt.name, func(t *testing.T) {
184+ req := httptest.NewRequest(http.MethodGet, "/v2/token", nil)
185+ req.RemoteAddr = tt.remoteAddr
186+
187+ result := isLocalhostRequest(req)
188+
189+ assert.Equal(t, tt.want, result)
190+ })
191+ }
192+}
@@ -0,0 +1,192 @@
1+package registry
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "testing"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+)
11+
12+func tokenTestLogger() zerowrap.Logger {
13+ return zerowrap.Default()
14+}
15+
16+func TestParseRequestedScopes_MultipleScopeParams(t *testing.T) {
17+ log := tokenTestLogger()
18+ h := &TokenHandler{log: log}
19+
20+ req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=repository:repo1:pull&scope=repository:repo2:push", nil)
21+
22+ scopes := h.parseRequestedScopes(req, log)
23+
24+ assert.Len(t, scopes, 2)
25+ assert.Contains(t, scopes, "repository:repo1:pull")
26+ assert.Contains(t, scopes, "repository:repo2:push")
27+}
28+
29+func TestParseRequestedScopes_InvalidScopeFiltered(t *testing.T) {
30+ log := tokenTestLogger()
31+ h := &TokenHandler{log: log}
32+
33+ tests := []struct {
34+ name string
35+ query string
36+ wantScopes []string
37+ wantDefault bool
38+ }{
39+ {
40+ name: "invalid format is skipped",
41+ query: "scope=invalid-no-colons&scope=repository:valid:pull",
42+ wantScopes: []string{"repository:valid:pull"},
43+ wantDefault: false,
44+ },
45+ {
46+ name: "missing actions is skipped",
47+ query: "scope=repository:myrepo&scope=repository:valid:pull",
48+ wantScopes: []string{"repository:valid:pull"},
49+ wantDefault: false,
50+ },
51+ {
52+ name: "all invalid returns default",
53+ query: "scope=invalid&scope=also-invalid",
54+ wantScopes: []string{"repository:*:pull"},
55+ wantDefault: true,
56+ },
57+ }
58+
59+ for _, tt := range tests {
60+ t.Run(tt.name, func(t *testing.T) {
61+ req := httptest.NewRequest(http.MethodGet, "/v2/token?"+tt.query, nil)
62+ scopes := h.parseRequestedScopes(req, log)
63+
64+ assert.Equal(t, tt.wantScopes, scopes)
65+ })
66+ }
67+}
68+
69+func TestParseRequestedScopes_NonRepositoryScopeRejected(t *testing.T) {
70+ log := tokenTestLogger()
71+ h := &TokenHandler{log: log}
72+
73+ tests := []struct {
74+ name string
75+ query string
76+ wantScopes []string
77+ }{
78+ {
79+ name: "registry scope is rejected",
80+ query: "scope=registry:catalog:*&scope=repository:myrepo:pull",
81+ wantScopes: []string{"repository:myrepo:pull"},
82+ },
83+ {
84+ name: "only non-repository scopes returns default",
85+ query: "scope=registry:catalog:*&scope=custom:something:action",
86+ wantScopes: []string{"repository:*:pull"},
87+ },
88+ }
89+
90+ for _, tt := range tests {
91+ t.Run(tt.name, func(t *testing.T) {
92+ req := httptest.NewRequest(http.MethodGet, "/v2/token?"+tt.query, nil)
93+ scopes := h.parseRequestedScopes(req, log)
94+
95+ assert.Equal(t, tt.wantScopes, scopes)
96+ })
97+ }
98+}
99+
100+func TestParseRequestedScopes_NoScopeReturnsDefault(t *testing.T) {
101+ log := tokenTestLogger()
102+ h := &TokenHandler{log: log}
103+
104+ req := httptest.NewRequest(http.MethodGet, "/v2/token", nil)
105+ scopes := h.parseRequestedScopes(req, log)
106+
107+ assert.Equal(t, []string{"repository:*:pull"}, scopes)
108+}
109+
110+func TestParseRequestedScopes_EmptyScopeParamReturnsDefault(t *testing.T) {
111+ log := tokenTestLogger()
112+ h := &TokenHandler{log: log}
113+
114+ req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=", nil)
115+ scopes := h.parseRequestedScopes(req, log)
116+
117+ // Empty scope param is invalid and should return default
118+ assert.Equal(t, []string{"repository:*:pull"}, scopes)
119+}
120+
121+func TestParseRequestedScopes_ValidScopesPreserved(t *testing.T) {
122+ log := tokenTestLogger()
123+ h := &TokenHandler{log: log}
124+
125+ tests := []struct {
126+ name string
127+ query string
128+ wantScopes []string
129+ }{
130+ {
131+ name: "single scope",
132+ query: "scope=repository:myrepo:pull",
133+ wantScopes: []string{"repository:myrepo:pull"},
134+ },
135+ {
136+ name: "multiple actions",
137+ query: "scope=repository:myrepo:push,pull",
138+ wantScopes: []string{"repository:myrepo:push,pull"},
139+ },
140+ {
141+ name: "wildcard repo",
142+ query: "scope=repository:*:pull",
143+ wantScopes: []string{"repository:*:pull"},
144+ },
145+ {
146+ name: "org wildcard",
147+ query: "scope=repository:myorg/*:push,pull",
148+ wantScopes: []string{"repository:myorg/*:push,pull"},
149+ },
150+ {
151+ name: "nested repo",
152+ query: "scope=repository:myorg/team/app:pull",
153+ wantScopes: []string{"repository:myorg/team/app:pull"},
154+ },
155+ }
156+
157+ for _, tt := range tests {
158+ t.Run(tt.name, func(t *testing.T) {
159+ req := httptest.NewRequest(http.MethodGet, "/v2/token?"+tt.query, nil)
160+ scopes := h.parseRequestedScopes(req, log)
161+
162+ assert.Equal(t, tt.wantScopes, scopes)
163+ })
164+ }
165+}
166+
167+func TestIsLocalhostRequest(t *testing.T) {
168+ tests := []struct {
169+ name string
170+ remoteAddr string
171+ want bool
172+ }{
173+ {"127.0.0.1 is localhost", "127.0.0.1:12345", true},
174+ {"127.0.0.2 is localhost", "127.0.0.2:12345", true},
175+ {"::1 is localhost", "[::1]:12345", true},
176+ {"::1 without brackets", "::1:12345", true},
177+ {"192.168.1.1 is not localhost", "192.168.1.1:12345", false},
178+ {"10.0.0.1 is not localhost", "10.0.0.1:12345", false},
179+ {"public IP is not localhost", "8.8.8.8:12345", false},
180+ }
181+
182+ for _, tt := range tests {
183+ t.Run(tt.name, func(t *testing.T) {
184+ req := httptest.NewRequest(http.MethodGet, "/v2/token", nil)
185+ req.RemoteAddr = tt.remoteAddr
186+
187+ result := isLocalhostRequest(req)
188+
189+ assert.Equal(t, tt.want, result)
190+ })
191+ }
192+}
A internal/domain/auth_test.go +347 -0
@@ -0,0 +1,347 @@
1+package domain
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func 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+
142+func 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+
292+func 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+
328+func TestParseScope_RoundTrip(t *testing.T) {
329+ // Test that parsing and stringifying produces the same result
330+ inputs := []string{
331+ "repository:myrepo:pull",
332+ "repository:myrepo:push,pull",
333+ "repository:*:*",
334+ "repository:myorg/*:push,pull",
335+ "repository:myorg/team/app:pull",
336+ }
337+
338+ for _, input := range inputs {
339+ t.Run(input, func(t *testing.T) {
340+ scope, err := ParseScope(input)
341+ require.NoError(t, err)
342+
343+ output := scope.String()
344+ assert.Equal(t, input, output)
345+ })
346+ }
347+}
@@ -0,0 +1,347 @@
1+package domain
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func 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+
142+func 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+
292+func 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+
328+func TestParseScope_RoundTrip(t *testing.T) {
329+ // Test that parsing and stringifying produces the same result
330+ inputs := []string{
331+ "repository:myrepo:pull",
332+ "repository:myrepo:push,pull",
333+ "repository:*:*",
334+ "repository:myorg/*:push,pull",
335+ "repository:myorg/team/app:pull",
336+ }
337+
338+ for _, input := range inputs {
339+ t.Run(input, func(t *testing.T) {
340+ scope, err := ParseScope(input)
341+ require.NoError(t, err)
342+
343+ output := scope.String()
344+ assert.Equal(t, input, output)
345+ })
346+ }
347+}
M pkg/validation/path.go +3 -2
@@ -16,8 +16,9 @@ import (
1616 var repoNameRegex = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)*(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$`)
1717
1818 // Reference (tag) validation per Docker spec:
19-// - Alphanumeric, dots, underscores, hyphens
20-// - Must start with alphanumeric
19+// - Case-sensitive alphanumeric (both uppercase and lowercase allowed)
20+// - Dots, underscores, and hyphens allowed after first character
21+// - Must start with an alphanumeric character
2122 // - Max 128 characters
2223 var referenceRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`)
2324
@@ -16,8 +16,9 @@ import (
16 var repoNameRegex = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)*(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$`)16 var repoNameRegex = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)*(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$`)
17 17
18 // Reference (tag) validation per Docker spec:18 // Reference (tag) validation per Docker spec:
19-// - Alphanumeric, dots, underscores, hyphens19+// - Case-sensitive alphanumeric (both uppercase and lowercase allowed)
20-// - Must start with alphanumeric20+// - Dots, underscores, and hyphens allowed after first character
21+// - Must start with an alphanumeric character
21 // - Max 128 characters22 // - Max 128 characters
22 var referenceRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`)23 var referenceRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`)
23 24

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close