1package auth
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/http/httptest"
9 "testing"
10 "time"
11
12 "github.com/bnema/zerowrap"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/mock"
15
16 "github.com/bnema/gordon/internal/adapters/in/http/httputil"
17 "github.com/bnema/gordon/internal/boundaries/in/mocks"
18 "github.com/bnema/gordon/internal/domain"
19)
20
21func testLogger() zerowrap.Logger {
22 return zerowrap.Default()
23}
24
25func TestHandler_Password_Success(t *testing.T) {
26 authSvc := mocks.NewMockAuthService(t)
27
28 authSvc.EXPECT().IsEnabled().Return(true)
29 authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
30 authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true)
31 authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 24*time.Hour).
32 Return("jwt-token-here", nil)
33
34 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
35
36 body := `{"username":"admin","password":"secret"}`
37 req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
38 req.Header.Set("Content-Type", "application/json")
39 rec := httptest.NewRecorder()
40
41 handler.ServeHTTP(rec, req)
42
43 assert.Equal(t, http.StatusOK, rec.Code)
44
45 var resp PasswordResponse
46 err := json.NewDecoder(rec.Body).Decode(&resp)
47 assert.NoError(t, err)
48 assert.Equal(t, "jwt-token-here", resp.Token)
49 assert.Equal(t, 24*60*60, resp.ExpiresIn)
50 assert.NotEmpty(t, resp.IssuedAt)
51}
52
53func TestHandler_Password_InvalidCredentials(t *testing.T) {
54 authSvc := mocks.NewMockAuthService(t)
55
56 authSvc.EXPECT().IsEnabled().Return(true)
57 authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
58 authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false)
59
60 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
61
62 body := `{"username":"admin","password":"wrongpassword"}`
63 req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
64 req.Header.Set("Content-Type", "application/json")
65 rec := httptest.NewRecorder()
66
67 handler.ServeHTTP(rec, req)
68
69 assert.Equal(t, http.StatusUnauthorized, rec.Code)
70 assert.Contains(t, rec.Body.String(), "invalid credentials")
71}
72
73func TestHandler_Password_AuthTypeToken(t *testing.T) {
74 authSvc := mocks.NewMockAuthService(t)
75
76 authSvc.EXPECT().IsEnabled().Return(true)
77 authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken)
78
79 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
80
81 body := `{"username":"admin","password":"secret"}`
82 req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
83 req.Header.Set("Content-Type", "application/json")
84 rec := httptest.NewRecorder()
85
86 handler.ServeHTTP(rec, req)
87
88 assert.Equal(t, http.StatusBadRequest, rec.Code)
89 assert.Contains(t, rec.Body.String(), "password authentication not configured")
90}
91
92func TestHandler_Password_AuthDisabled(t *testing.T) {
93 authSvc := mocks.NewMockAuthService(t)
94
95 authSvc.EXPECT().IsEnabled().Return(false)
96
97 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
98
99 body := `{"username":"admin","password":"secret"}`
100 req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
101 req.Header.Set("Content-Type", "application/json")
102 rec := httptest.NewRecorder()
103
104 handler.ServeHTTP(rec, req)
105
106 assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
107 assert.Contains(t, rec.Body.String(), "authentication is required")
108}
109
110func TestHandler_Password_MissingFields(t *testing.T) {
111 authSvc := mocks.NewMockAuthService(t)
112
113 authSvc.EXPECT().IsEnabled().Return(true)
114 authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
115
116 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
117
118 tests := []struct {
119 name string
120 body string
121 }{
122 {"missing username", `{"password":"secret"}`},
123 {"missing password", `{"username":"admin"}`},
124 {"empty username", `{"username":"","password":"secret"}`},
125 {"empty password", `{"username":"admin","password":""}`},
126 }
127
128 for _, tt := range tests {
129 t.Run(tt.name, func(t *testing.T) {
130 req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(tt.body))
131 req.Header.Set("Content-Type", "application/json")
132 rec := httptest.NewRecorder()
133
134 handler.ServeHTTP(rec, req)
135
136 assert.Equal(t, http.StatusBadRequest, rec.Code)
137 })
138 }
139}
140
141func TestHandler_Password_MethodNotAllowed(t *testing.T) {
142 handler := NewHandler(nil, InternalAuth{}, testLogger())
143
144 methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch}
145
146 for _, method := range methods {
147 t.Run(method, func(t *testing.T) {
148 req := httptest.NewRequest(method, "/auth/password", nil)
149 rec := httptest.NewRecorder()
150
151 handler.ServeHTTP(rec, req)
152
153 assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
154 })
155 }
156}
157
158func TestHandler_Token_Success(t *testing.T) {
159 authSvc := mocks.NewMockAuthService(t)
160
161 authSvc.EXPECT().IsEnabled().Return(true)
162 // ValidateToken is tried first; plain password fails JWT parsing
163 authSvc.EXPECT().ValidateToken(mock.Anything, "secret").Return(nil, fmt.Errorf("not a token"))
164 authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
165 authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true)
166 authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 5*time.Minute).
167 Return("access-token", nil)
168
169 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
170
171 req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil)
172 req.SetBasicAuth("admin", "secret")
173 rec := httptest.NewRecorder()
174
175 handler.ServeHTTP(rec, req)
176
177 assert.Equal(t, http.StatusOK, rec.Code)
178
179 var resp map[string]any
180 err := json.NewDecoder(rec.Body).Decode(&resp)
181 assert.NoError(t, err)
182 assert.Equal(t, "access-token", resp["token"])
183 assert.Equal(t, float64(300), resp["expires_in"])
184}
185
186func TestHandler_Token_MissingCredentials(t *testing.T) {
187 authSvc := mocks.NewMockAuthService(t)
188
189 authSvc.EXPECT().IsEnabled().Return(true)
190
191 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
192
193 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
194 rec := httptest.NewRecorder()
195
196 handler.ServeHTTP(rec, req)
197
198 assert.Equal(t, http.StatusUnauthorized, rec.Code)
199}
200
201func TestHandler_Token_AuthDisabled(t *testing.T) {
202 authSvc := mocks.NewMockAuthService(t)
203
204 authSvc.EXPECT().IsEnabled().Return(false)
205
206 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
207
208 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
209 rec := httptest.NewRecorder()
210
211 handler.ServeHTTP(rec, req)
212
213 assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
214 assert.Contains(t, rec.Body.String(), "authentication is required")
215}
216
217func TestHandler_Token_InvalidCredentials(t *testing.T) {
218 authSvc := mocks.NewMockAuthService(t)
219
220 authSvc.EXPECT().IsEnabled().Return(true)
221 authSvc.EXPECT().ValidateToken(mock.Anything, "wrongpassword").Return(nil, fmt.Errorf("not a token"))
222 authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
223 authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false)
224
225 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
226
227 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
228 req.SetBasicAuth("admin", "wrongpassword")
229 rec := httptest.NewRecorder()
230
231 handler.ServeHTTP(rec, req)
232
233 assert.Equal(t, http.StatusUnauthorized, rec.Code)
234}
235
236func TestHandler_Token_InternalAuth(t *testing.T) {
237 authSvc := mocks.NewMockAuthService(t)
238
239 authSvc.EXPECT().IsEnabled().Return(true)
240 authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 5*time.Minute).
241 Return("internal-access-token", nil)
242
243 internalAuth := InternalAuth{
244 Username: "gordon-internal",
245 Password: "internal-secret",
246 }
247 handler := NewHandler(authSvc, internalAuth, testLogger())
248
249 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
250 req.RemoteAddr = "127.0.0.1:12345"
251 req.SetBasicAuth("gordon-internal", "internal-secret")
252 rec := httptest.NewRecorder()
253
254 handler.ServeHTTP(rec, req)
255
256 assert.Equal(t, http.StatusOK, rec.Code)
257
258 var resp map[string]any
259 err := json.NewDecoder(rec.Body).Decode(&resp)
260 assert.NoError(t, err)
261 assert.Equal(t, "internal-access-token", resp["token"])
262}
263
264func TestHandler_Token_TokenAuth(t *testing.T) {
265 authSvc := mocks.NewMockAuthService(t)
266
267 authSvc.EXPECT().IsEnabled().Return(true)
268 authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
269 Subject: "myuser",
270 Scopes: []string{"repository:*:pull"},
271 }, nil)
272 authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute).
273 Return("short-lived-token", nil)
274
275 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
276
277 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
278 req.SetBasicAuth("myuser", "existing-jwt-token")
279 rec := httptest.NewRecorder()
280
281 handler.ServeHTTP(rec, req)
282
283 assert.Equal(t, http.StatusOK, rec.Code)
284
285 var resp map[string]any
286 err := json.NewDecoder(rec.Body).Decode(&resp)
287 assert.NoError(t, err)
288 assert.Equal(t, "short-lived-token", resp["token"])
289}
290
291func TestHandler_Token_TokenAuthScopeIntersection(t *testing.T) {
292 authSvc := mocks.NewMockAuthService(t)
293
294 authSvc.EXPECT().IsEnabled().Return(true)
295 authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
296 Subject: "myuser",
297 Scopes: []string{"repository:myrepo:pull"},
298 }, nil)
299 authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 5*time.Minute).
300 Return("scoped-token", nil)
301
302 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
303
304 req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull,push", nil)
305 req.SetBasicAuth("myuser", "existing-jwt-token")
306 rec := httptest.NewRecorder()
307
308 handler.ServeHTTP(rec, req)
309
310 assert.Equal(t, http.StatusOK, rec.Code)
311 var resp map[string]any
312 err := json.NewDecoder(rec.Body).Decode(&resp)
313 assert.NoError(t, err)
314 assert.Equal(t, "scoped-token", resp["token"])
315}
316
317func TestHandler_Token_TokenAuthScopeDenied(t *testing.T) {
318 authSvc := mocks.NewMockAuthService(t)
319
320 authSvc.EXPECT().IsEnabled().Return(true)
321 authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
322 Subject: "myuser",
323 Scopes: []string{"repository:myrepo:pull"},
324 }, nil)
325
326 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
327
328 req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:push", nil)
329 req.SetBasicAuth("myuser", "existing-jwt-token")
330 rec := httptest.NewRecorder()
331
332 handler.ServeHTTP(rec, req)
333
334 assert.Equal(t, http.StatusForbidden, rec.Code)
335 assert.Contains(t, rec.Body.String(), "insufficient scope")
336}
337
338func TestHandler_Token_PasswordAuthWithJWT(t *testing.T) {
339 authSvc := mocks.NewMockAuthService(t)
340
341 // Auth type is password, but the client sends a JWT (from gordon auth login)
342 authSvc.EXPECT().IsEnabled().Return(true)
343 authSvc.EXPECT().ValidateToken(mock.Anything, "jwt-from-login").Return(&domain.TokenClaims{
344 Subject: "brissou",
345 Scopes: []string{"*"},
346 }, nil)
347 authSvc.EXPECT().GenerateAccessToken(mock.Anything, "brissou", []string{"repository:*:push,pull"}, 5*time.Minute).
348 Return("short-lived-token", nil)
349
350 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
351
352 req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:*:push,pull", nil)
353 req.SetBasicAuth("brissou", "jwt-from-login")
354 rec := httptest.NewRecorder()
355
356 handler.ServeHTTP(rec, req)
357
358 assert.Equal(t, http.StatusOK, rec.Code)
359 var resp map[string]any
360 err := json.NewDecoder(rec.Body).Decode(&resp)
361 assert.NoError(t, err)
362 assert.Equal(t, "short-lived-token", resp["token"])
363}
364
365func TestHandler_Token_MethodNotAllowed(t *testing.T) {
366 handler := NewHandler(nil, InternalAuth{}, testLogger())
367
368 methods := []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch}
369
370 for _, method := range methods {
371 t.Run(method, func(t *testing.T) {
372 req := httptest.NewRequest(method, "/auth/token", nil)
373 rec := httptest.NewRecorder()
374
375 handler.ServeHTTP(rec, req)
376
377 assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
378 })
379 }
380}
381
382func TestHandler_NotFound(t *testing.T) {
383 handler := NewHandler(nil, InternalAuth{}, testLogger())
384
385 paths := []string{"/auth/unknown", "/auth/", "/auth"}
386
387 for _, path := range paths {
388 t.Run(path, func(t *testing.T) {
389 req := httptest.NewRequest(http.MethodGet, path, nil)
390 rec := httptest.NewRecorder()
391
392 handler.ServeHTTP(rec, req)
393
394 assert.Equal(t, http.StatusNotFound, rec.Code)
395 })
396 }
397}
398
399func TestParseRequestedScopes_MultipleScopeParams(t *testing.T) {
400 log := testLogger()
401 h := &Handler{log: log}
402
403 req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:repo1:pull&scope=repository:repo2:push", nil)
404
405 scopes := h.parseRequestedScopes(req, log)
406
407 assert.Len(t, scopes, 2)
408 assert.Contains(t, scopes, "repository:repo1:pull")
409 assert.Contains(t, scopes, "repository:repo2:push")
410}
411
412func TestParseRequestedScopes_InvalidScopeFiltered(t *testing.T) {
413 log := testLogger()
414 h := &Handler{log: log}
415
416 tests := []struct {
417 name string
418 query string
419 wantScopes []string
420 }{
421 {
422 name: "invalid format is skipped",
423 query: "scope=invalid-no-colons&scope=repository:valid:pull",
424 wantScopes: []string{"repository:valid:pull"},
425 },
426 {
427 name: "missing actions is skipped",
428 query: "scope=repository:myrepo&scope=repository:valid:pull",
429 wantScopes: []string{"repository:valid:pull"},
430 },
431 {
432 name: "all invalid returns default",
433 query: "scope=invalid&scope=also-invalid",
434 wantScopes: []string{"repository:*:pull"},
435 },
436 }
437
438 for _, tt := range tests {
439 t.Run(tt.name, func(t *testing.T) {
440 req := httptest.NewRequest(http.MethodGet, "/auth/token?"+tt.query, nil)
441 scopes := h.parseRequestedScopes(req, log)
442
443 assert.Equal(t, tt.wantScopes, scopes)
444 })
445 }
446}
447
448func TestParseRequestedScopes_NoScopeReturnsDefault(t *testing.T) {
449 log := testLogger()
450 h := &Handler{log: log}
451
452 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
453 scopes := h.parseRequestedScopes(req, log)
454
455 assert.Equal(t, []string{"repository:*:pull"}, scopes)
456}
457
458func TestIsLocalhostRequest(t *testing.T) {
459 tests := []struct {
460 name string
461 remoteAddr string
462 want bool
463 }{
464 {"127.0.0.1 is localhost", "127.0.0.1:12345", true},
465 {"127.0.0.2 is localhost", "127.0.0.2:12345", true},
466 {"::1 is localhost", "[::1]:12345", true},
467 {"192.168.1.1 is not localhost", "192.168.1.1:12345", false},
468 {"10.0.0.1 is not localhost", "10.0.0.1:12345", false},
469 {"public IP is not localhost", "8.8.8.8:12345", false},
470 }
471
472 for _, tt := range tests {
473 t.Run(tt.name, func(t *testing.T) {
474 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
475 req.RemoteAddr = tt.remoteAddr
476
477 result := httputil.IsLocalhostRequest(req)
478
479 assert.Equal(t, tt.want, result)
480 })
481 }
482}
483
484func TestHandler_isInternalAuth(t *testing.T) {
485 tests := []struct {
486 name string
487 username string
488 password string
489 internalAuth InternalAuth
490 wantResult bool
491 }{
492 {
493 name: "correct credentials",
494 username: "gordon-internal",
495 password: "internal-secret",
496 internalAuth: InternalAuth{
497 Username: "gordon-internal",
498 Password: "internal-secret",
499 },
500 wantResult: true,
501 },
502 {
503 name: "incorrect username",
504 username: "wrong-username",
505 password: "internal-secret",
506 internalAuth: InternalAuth{
507 Username: "gordon-internal",
508 Password: "internal-secret",
509 },
510 wantResult: false,
511 },
512 {
513 name: "incorrect password",
514 username: "gordon-internal",
515 password: "wrong-password",
516 internalAuth: InternalAuth{
517 Username: "gordon-internal",
518 Password: "internal-secret",
519 },
520 wantResult: false,
521 },
522 {
523 name: "empty internal auth credentials",
524 username: "gordon-internal",
525 password: "internal-secret",
526 internalAuth: InternalAuth{
527 Username: "",
528 Password: "",
529 },
530 wantResult: false,
531 },
532 {
533 name: "partially empty internal auth - empty password",
534 username: "gordon-internal",
535 password: "internal-secret",
536 internalAuth: InternalAuth{
537 Username: "gordon-internal",
538 Password: "",
539 },
540 wantResult: false,
541 },
542 {
543 name: "partially empty internal auth - empty username",
544 username: "gordon-internal",
545 password: "internal-secret",
546 internalAuth: InternalAuth{
547 Username: "",
548 Password: "internal-secret",
549 },
550 wantResult: false,
551 },
552 }
553
554 for _, tt := range tests {
555 t.Run(tt.name, func(t *testing.T) {
556 h := &Handler{
557 internalAuth: tt.internalAuth,
558 }
559
560 result := h.isInternalAuth(tt.username, tt.password)
561
562 assert.Equal(t, tt.wantResult, result)
563 })
564 }
565}