1package middleware
2
3import (
4 "context"
5 "errors"
6 "net/http"
7 "net/http/httptest"
8 "testing"
9 "time"
10
11 "github.com/bnema/zerowrap"
12 "github.com/stretchr/testify/assert"
13
14 "github.com/bnema/gordon/internal/domain"
15)
16
17func testLogger() zerowrap.Logger {
18 return zerowrap.Default()
19}
20
21func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) {
22 log := testLogger()
23 tokenCalled := false
24 authSvc := stubAuthService{
25 enabled: true,
26 authType: domain.AuthTypeToken,
27 validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
28 tokenCalled = true
29 return nil, errors.New("not a token")
30 },
31 }
32 internalAuth := InternalRegistryAuth{
33 Username: "internal",
34 Password: "secret",
35 }
36
37 handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
38 w.WriteHeader(http.StatusOK)
39 }))
40
41 req := httptest.NewRequest("GET", "/v2/", nil)
42 req.RemoteAddr = "127.0.0.1:12345"
43 req.SetBasicAuth("internal", "secret")
44 rec := httptest.NewRecorder()
45
46 handler.ServeHTTP(rec, req)
47
48 assert.Equal(t, http.StatusOK, rec.Code)
49 assert.False(t, tokenCalled, "auth service should not be used for internal loopback auth")
50}
51
52func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) {
53 log := testLogger()
54 tokenCalled := false
55 authSvc := stubAuthService{
56 enabled: true,
57 authType: domain.AuthTypeToken,
58 validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
59 tokenCalled = true
60 return nil, errors.New("invalid token")
61 },
62 }
63 internalAuth := InternalRegistryAuth{
64 Username: "internal",
65 Password: "secret",
66 }
67
68 handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
69 w.WriteHeader(http.StatusOK)
70 }))
71
72 req := httptest.NewRequest("GET", "/v2/", nil)
73 req.RemoteAddr = "127.0.0.1:12345"
74 req.SetBasicAuth("internal", "wrong")
75 rec := httptest.NewRecorder()
76
77 handler.ServeHTTP(rec, req)
78
79 assert.Equal(t, http.StatusUnauthorized, rec.Code)
80 assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer")
81 assert.True(t, tokenCalled, "auth service should be used when internal auth fails")
82}
83
84func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) {
85 log := testLogger()
86 tokenCalled := false
87 authSvc := stubAuthService{
88 enabled: true,
89 authType: domain.AuthTypeToken,
90 validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
91 tokenCalled = true
92 return nil, errors.New("invalid token")
93 },
94 }
95 internalAuth := InternalRegistryAuth{
96 Username: "internal",
97 Password: "secret",
98 }
99
100 handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
101 w.WriteHeader(http.StatusOK)
102 }))
103
104 req := httptest.NewRequest("GET", "/v2/", nil)
105 req.RemoteAddr = "192.168.1.10:12345"
106 req.SetBasicAuth("internal", "secret")
107 rec := httptest.NewRecorder()
108
109 handler.ServeHTTP(rec, req)
110
111 assert.Equal(t, http.StatusUnauthorized, rec.Code)
112 assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer")
113 assert.True(t, tokenCalled, "auth service should be used for non-localhost requests")
114}
115
116type stubAuthService struct {
117 enabled bool
118 authType domain.AuthType
119 validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
120}
121
122func (s stubAuthService) GetAuthType() domain.AuthType {
123 return s.authType
124}
125
126func (s stubAuthService) IsEnabled() bool {
127 return s.enabled
128}
129
130func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
131 if s.validateToken != nil {
132 return s.validateToken(ctx, tokenString)
133 }
134 return nil, errors.New("not implemented")
135}
136
137func (s stubAuthService) GenerateToken(context.Context, string, []string, time.Duration) (string, error) {
138 return "", errors.New("not implemented")
139}
140
141func (s stubAuthService) GenerateAccessToken(context.Context, string, []string, time.Duration) (string, error) {
142 return "", errors.New("not implemented")
143}
144
145func (s stubAuthService) RevokeToken(context.Context, string) error {
146 return errors.New("not implemented")
147}
148
149func (s stubAuthService) RevokeAllTokens(context.Context) (int, error) {
150 return 0, errors.New("not implemented")
151}
152
153func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) {
154 return nil, errors.New("not implemented")
155}
156
157func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, error) {
158 return nil, errors.New("not implemented")
159}
160
161func (s stubAuthService) ExtendToken(context.Context, string) (string, error) {
162 return "", errors.New("not implemented")
163}
164
165func (s stubAuthService) GetAccessTokenTTL() time.Duration {
166 return 15 * time.Minute
167}
168
169// Tests for checkScopeAccess function
170
171func TestCheckScopeAccess_ActionMapping(t *testing.T) {
172 log := testLogger()
173
174 tests := []struct {
175 name string
176 method string
177 expectedAction string
178 }{
179 {"GET maps to pull", http.MethodGet, "pull"},
180 {"HEAD maps to pull", http.MethodHead, "pull"},
181 {"PUT maps to push", http.MethodPut, "push"},
182 {"POST maps to push", http.MethodPost, "push"},
183 {"PATCH maps to push", http.MethodPatch, "push"},
184 {"DELETE maps to push", http.MethodDelete, "push"},
185 {"OPTIONS defaults to pull", http.MethodOptions, "pull"},
186 }
187
188 for _, tt := range tests {
189 t.Run(tt.name, func(t *testing.T) {
190 claims := &domain.TokenClaims{
191 Scopes: []string{"repository:myrepo:" + tt.expectedAction},
192 }
193
194 req := httptest.NewRequest(tt.method, "/v2/myrepo/manifests/latest", nil)
195 result := checkScopeAccess(req, claims, log)
196
197 assert.True(t, result, "method %s should be allowed with %s scope", tt.method, tt.expectedAction)
198 })
199 }
200}
201
202func TestCheckScopeAccess_RepoNameExtraction(t *testing.T) {
203 log := testLogger()
204
205 tests := []struct {
206 name string
207 path string
208 wantRepo string
209 scopes []string
210 want bool
211 }{
212 {
213 name: "simple repo with manifests",
214 path: "/v2/myrepo/manifests/latest",
215 scopes: []string{"repository:myrepo:pull"},
216 want: true,
217 },
218 {
219 name: "simple repo with blobs",
220 path: "/v2/myrepo/blobs/sha256:abc123",
221 scopes: []string{"repository:myrepo:pull"},
222 want: true,
223 },
224 {
225 name: "nested repo with manifests",
226 path: "/v2/myorg/myapp/manifests/v1.0",
227 scopes: []string{"repository:myorg/myapp:pull"},
228 want: true,
229 },
230 {
231 name: "deeply nested repo",
232 path: "/v2/myorg/team/myapp/manifests/latest",
233 scopes: []string{"repository:myorg/team/myapp:pull"},
234 want: true,
235 },
236 {
237 name: "repo with tags list",
238 path: "/v2/myrepo/tags/list",
239 scopes: []string{"repository:myrepo:pull"},
240 want: true,
241 },
242 {
243 name: "wrong repo name",
244 path: "/v2/myrepo/manifests/latest",
245 scopes: []string{"repository:otherrepo:pull"},
246 want: false,
247 },
248 }
249
250 for _, tt := range tests {
251 t.Run(tt.name, func(t *testing.T) {
252 claims := &domain.TokenClaims{Scopes: tt.scopes}
253 req := httptest.NewRequest(http.MethodGet, tt.path, nil)
254 result := checkScopeAccess(req, claims, log)
255 assert.Equal(t, tt.want, result)
256 })
257 }
258}
259
260func TestCheckScopeAccess_WildcardMatching(t *testing.T) {
261 log := testLogger()
262
263 tests := []struct {
264 name string
265 path string
266 scopes []string
267 want bool
268 }{
269 {
270 name: "wildcard repo matches any",
271 path: "/v2/anyrepo/manifests/latest",
272 scopes: []string{"repository:*:pull"},
273 want: true,
274 },
275 {
276 name: "org wildcard matches repo in org",
277 path: "/v2/myorg/myapp/manifests/latest",
278 scopes: []string{"repository:myorg/*:pull"},
279 want: true,
280 },
281 {
282 name: "org wildcard does not match other org",
283 path: "/v2/otherorg/myapp/manifests/latest",
284 scopes: []string{"repository:myorg/*:pull"},
285 want: false,
286 },
287 {
288 name: "wildcard action grants push",
289 path: "/v2/myrepo/manifests/latest",
290 scopes: []string{"repository:myrepo:*"},
291 want: true,
292 },
293 }
294
295 for _, tt := range tests {
296 t.Run(tt.name, func(t *testing.T) {
297 claims := &domain.TokenClaims{Scopes: tt.scopes}
298 req := httptest.NewRequest(http.MethodGet, tt.path, nil)
299 result := checkScopeAccess(req, claims, log)
300 assert.Equal(t, tt.want, result)
301 })
302 }
303}
304
305func TestCheckScopeAccess_SpecialRoutes(t *testing.T) {
306 log := testLogger()
307
308 tests := []struct {
309 name string
310 path string
311 scopes []string
312 want bool
313 }{
314 {
315 name: "v2 root is allowed",
316 path: "/v2/",
317 scopes: []string{"repository:myrepo:pull"},
318 want: true,
319 },
320 {
321 name: "non-v2 path is allowed",
322 path: "/healthz",
323 scopes: []string{"repository:myrepo:pull"},
324 want: true,
325 },
326 {
327 name: "catalog path is handled",
328 path: "/v2/_catalog",
329 scopes: []string{"repository:myrepo:pull"},
330 want: true,
331 },
332 }
333
334 for _, tt := range tests {
335 t.Run(tt.name, func(t *testing.T) {
336 claims := &domain.TokenClaims{Scopes: tt.scopes}
337 req := httptest.NewRequest(http.MethodGet, tt.path, nil)
338 result := checkScopeAccess(req, claims, log)
339 assert.Equal(t, tt.want, result)
340 })
341 }
342}
343
344func TestCheckScopeAccess_DenialCases(t *testing.T) {
345 log := testLogger()
346
347 tests := []struct {
348 name string
349 method string
350 path string
351 scopes []string
352 want bool
353 }{
354 {
355 name: "pull scope denies push",
356 method: http.MethodPut,
357 path: "/v2/myrepo/manifests/latest",
358 scopes: []string{"repository:myrepo:pull"},
359 want: false,
360 },
361 {
362 name: "wrong repo denies access",
363 method: http.MethodGet,
364 path: "/v2/myrepo/manifests/latest",
365 scopes: []string{"repository:otherrepo:pull"},
366 want: false,
367 },
368 {
369 name: "empty scopes denies access",
370 method: http.MethodGet,
371 path: "/v2/myrepo/manifests/latest",
372 scopes: []string{},
373 want: false,
374 },
375 {
376 name: "invalid scope format is skipped",
377 method: http.MethodGet,
378 path: "/v2/myrepo/manifests/latest",
379 scopes: []string{"invalid-scope-format"},
380 want: false,
381 },
382 }
383
384 for _, tt := range tests {
385 t.Run(tt.name, func(t *testing.T) {
386 claims := &domain.TokenClaims{Scopes: tt.scopes}
387 req := httptest.NewRequest(tt.method, tt.path, nil)
388 result := checkScopeAccess(req, claims, log)
389 assert.Equal(t, tt.want, result)
390 })
391 }
392}