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 called := false
24 authSvc := stubAuthService{
25 enabled: true,
26 authType: domain.AuthTypePassword,
27 validatePassword: func(_ context.Context, _ string, _ string) bool {
28 called = true
29 return false
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, called, "auth service should not be used for internal loopback auth")
50}
51
52func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) {
53 log := testLogger()
54 called := false
55 authSvc := stubAuthService{
56 enabled: true,
57 authType: domain.AuthTypePassword,
58 validatePassword: func(_ context.Context, _ string, _ string) bool {
59 called = true
60 return false
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.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
81 assert.True(t, called, "auth service should be used when internal auth fails")
82}
83
84func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) {
85 log := testLogger()
86 called := false
87 authSvc := stubAuthService{
88 enabled: true,
89 authType: domain.AuthTypePassword,
90 validatePassword: func(_ context.Context, _ string, _ string) bool {
91 called = true
92 return false
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.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
113 assert.True(t, called, "auth service should be used for non-localhost requests")
114}
115
116type stubAuthService struct {
117 enabled bool
118 authType domain.AuthType
119 validatePassword func(ctx context.Context, username, password string) bool
120 validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
121}
122
123func (s stubAuthService) GetAuthType() domain.AuthType {
124 return s.authType
125}
126
127func (s stubAuthService) IsEnabled() bool {
128 return s.enabled
129}
130
131func (s stubAuthService) ValidatePassword(ctx context.Context, username, password string) bool {
132 if s.validatePassword != nil {
133 return s.validatePassword(ctx, username, password)
134 }
135 return false
136}
137
138func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
139 if s.validateToken != nil {
140 return s.validateToken(ctx, tokenString)
141 }
142 return nil, errors.New("not implemented")
143}
144
145func (s stubAuthService) GenerateToken(context.Context, string, []string, time.Duration) (string, error) {
146 return "", errors.New("not implemented")
147}
148
149func (s stubAuthService) GenerateAccessToken(context.Context, string, []string, time.Duration) (string, error) {
150 return "", errors.New("not implemented")
151}
152
153func (s stubAuthService) RevokeToken(context.Context, string) error {
154 return errors.New("not implemented")
155}
156
157func (s stubAuthService) RevokeAllTokens(context.Context) (int, error) {
158 return 0, errors.New("not implemented")
159}
160
161func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) {
162 return nil, errors.New("not implemented")
163}
164
165func (s stubAuthService) GeneratePasswordHash(string) (string, error) {
166 return "", errors.New("not implemented")
167}
168
169func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, error) {
170 return nil, errors.New("not implemented")
171}
172
173func (s stubAuthService) ExtendToken(context.Context, string) (string, error) {
174 return "", errors.New("not implemented")
175}
176
177// Tests for checkScopeAccess function
178
179func TestCheckScopeAccess_ActionMapping(t *testing.T) {
180 log := testLogger()
181
182 tests := []struct {
183 name string
184 method string
185 expectedAction string
186 }{
187 {"GET maps to pull", http.MethodGet, "pull"},
188 {"HEAD maps to pull", http.MethodHead, "pull"},
189 {"PUT maps to push", http.MethodPut, "push"},
190 {"POST maps to push", http.MethodPost, "push"},
191 {"PATCH maps to push", http.MethodPatch, "push"},
192 {"DELETE maps to push", http.MethodDelete, "push"},
193 {"OPTIONS defaults to pull", http.MethodOptions, "pull"},
194 }
195
196 for _, tt := range tests {
197 t.Run(tt.name, func(t *testing.T) {
198 claims := &domain.TokenClaims{
199 Scopes: []string{"repository:myrepo:" + tt.expectedAction},
200 }
201
202 req := httptest.NewRequest(tt.method, "/v2/myrepo/manifests/latest", nil)
203 result := checkScopeAccess(req, claims, log)
204
205 assert.True(t, result, "method %s should be allowed with %s scope", tt.method, tt.expectedAction)
206 })
207 }
208}
209
210func TestCheckScopeAccess_RepoNameExtraction(t *testing.T) {
211 log := testLogger()
212
213 tests := []struct {
214 name string
215 path string
216 wantRepo string
217 scopes []string
218 want bool
219 }{
220 {
221 name: "simple repo with manifests",
222 path: "/v2/myrepo/manifests/latest",
223 scopes: []string{"repository:myrepo:pull"},
224 want: true,
225 },
226 {
227 name: "simple repo with blobs",
228 path: "/v2/myrepo/blobs/sha256:abc123",
229 scopes: []string{"repository:myrepo:pull"},
230 want: true,
231 },
232 {
233 name: "nested repo with manifests",
234 path: "/v2/myorg/myapp/manifests/v1.0",
235 scopes: []string{"repository:myorg/myapp:pull"},
236 want: true,
237 },
238 {
239 name: "deeply nested repo",
240 path: "/v2/myorg/team/myapp/manifests/latest",
241 scopes: []string{"repository:myorg/team/myapp:pull"},
242 want: true,
243 },
244 {
245 name: "repo with tags list",
246 path: "/v2/myrepo/tags/list",
247 scopes: []string{"repository:myrepo:pull"},
248 want: true,
249 },
250 {
251 name: "wrong repo name",
252 path: "/v2/myrepo/manifests/latest",
253 scopes: []string{"repository:otherrepo:pull"},
254 want: false,
255 },
256 }
257
258 for _, tt := range tests {
259 t.Run(tt.name, func(t *testing.T) {
260 claims := &domain.TokenClaims{Scopes: tt.scopes}
261 req := httptest.NewRequest(http.MethodGet, tt.path, nil)
262 result := checkScopeAccess(req, claims, log)
263 assert.Equal(t, tt.want, result)
264 })
265 }
266}
267
268func TestCheckScopeAccess_WildcardMatching(t *testing.T) {
269 log := testLogger()
270
271 tests := []struct {
272 name string
273 path string
274 scopes []string
275 want bool
276 }{
277 {
278 name: "wildcard repo matches any",
279 path: "/v2/anyrepo/manifests/latest",
280 scopes: []string{"repository:*:pull"},
281 want: true,
282 },
283 {
284 name: "org wildcard matches repo in org",
285 path: "/v2/myorg/myapp/manifests/latest",
286 scopes: []string{"repository:myorg/*:pull"},
287 want: true,
288 },
289 {
290 name: "org wildcard does not match other org",
291 path: "/v2/otherorg/myapp/manifests/latest",
292 scopes: []string{"repository:myorg/*:pull"},
293 want: false,
294 },
295 {
296 name: "wildcard action grants push",
297 path: "/v2/myrepo/manifests/latest",
298 scopes: []string{"repository:myrepo:*"},
299 want: true,
300 },
301 }
302
303 for _, tt := range tests {
304 t.Run(tt.name, func(t *testing.T) {
305 claims := &domain.TokenClaims{Scopes: tt.scopes}
306 req := httptest.NewRequest(http.MethodGet, tt.path, nil)
307 result := checkScopeAccess(req, claims, log)
308 assert.Equal(t, tt.want, result)
309 })
310 }
311}
312
313func TestCheckScopeAccess_SpecialRoutes(t *testing.T) {
314 log := testLogger()
315
316 tests := []struct {
317 name string
318 path string
319 scopes []string
320 want bool
321 }{
322 {
323 name: "v2 root is allowed",
324 path: "/v2/",
325 scopes: []string{"repository:myrepo:pull"},
326 want: true,
327 },
328 {
329 name: "non-v2 path is allowed",
330 path: "/healthz",
331 scopes: []string{"repository:myrepo:pull"},
332 want: true,
333 },
334 {
335 name: "catalog path is handled",
336 path: "/v2/_catalog",
337 scopes: []string{"repository:myrepo:pull"},
338 want: true,
339 },
340 }
341
342 for _, tt := range tests {
343 t.Run(tt.name, func(t *testing.T) {
344 claims := &domain.TokenClaims{Scopes: tt.scopes}
345 req := httptest.NewRequest(http.MethodGet, tt.path, nil)
346 result := checkScopeAccess(req, claims, log)
347 assert.Equal(t, tt.want, result)
348 })
349 }
350}
351
352func TestCheckScopeAccess_DenialCases(t *testing.T) {
353 log := testLogger()
354
355 tests := []struct {
356 name string
357 method string
358 path string
359 scopes []string
360 want bool
361 }{
362 {
363 name: "pull scope denies push",
364 method: http.MethodPut,
365 path: "/v2/myrepo/manifests/latest",
366 scopes: []string{"repository:myrepo:pull"},
367 want: false,
368 },
369 {
370 name: "wrong repo denies access",
371 method: http.MethodGet,
372 path: "/v2/myrepo/manifests/latest",
373 scopes: []string{"repository:otherrepo:pull"},
374 want: false,
375 },
376 {
377 name: "empty scopes denies access",
378 method: http.MethodGet,
379 path: "/v2/myrepo/manifests/latest",
380 scopes: []string{},
381 want: false,
382 },
383 {
384 name: "invalid scope format is skipped",
385 method: http.MethodGet,
386 path: "/v2/myrepo/manifests/latest",
387 scopes: []string{"invalid-scope-format"},
388 want: false,
389 },
390 }
391
392 for _, tt := range tests {
393 t.Run(tt.name, func(t *testing.T) {
394 claims := &domain.TokenClaims{Scopes: tt.scopes}
395 req := httptest.NewRequest(tt.method, tt.path, nil)
396 result := checkScopeAccess(req, claims, log)
397 assert.Equal(t, tt.want, result)
398 })
399 }
400}