1package auth
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/bnema/zerowrap"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"
12 "golang.org/x/crypto/bcrypt"
13
14 "gordon/internal/boundaries/out/mocks"
15 "gordon/internal/domain"
16)
17
18func testContext() context.Context {
19 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
20}
21
22func TestService_GetAuthType(t *testing.T) {
23 tests := []struct {
24 name string
25 authType domain.AuthType
26 }{
27 {"password type", domain.AuthTypePassword},
28 {"token type", domain.AuthTypeToken},
29 }
30
31 for _, tt := range tests {
32 t.Run(tt.name, func(t *testing.T) {
33 svc := NewService(Config{AuthType: tt.authType}, nil, zerowrap.Default())
34 assert.Equal(t, tt.authType, svc.GetAuthType())
35 })
36 }
37}
38
39func TestService_IsEnabled(t *testing.T) {
40 tests := []struct {
41 name string
42 enabled bool
43 }{
44 {"enabled", true},
45 {"disabled", false},
46 }
47
48 for _, tt := range tests {
49 t.Run(tt.name, func(t *testing.T) {
50 svc := NewService(Config{Enabled: tt.enabled}, nil, zerowrap.Default())
51 assert.Equal(t, tt.enabled, svc.IsEnabled())
52 })
53 }
54}
55
56func TestService_ValidatePassword_Success(t *testing.T) {
57 password := "testpassword123"
58 hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
59 require.NoError(t, err)
60
61 svc := NewService(Config{
62 Enabled: true,
63 AuthType: domain.AuthTypePassword,
64 Username: "testuser",
65 PasswordHash: string(hash),
66 }, nil, zerowrap.Default())
67
68 ctx := testContext()
69 assert.True(t, svc.ValidatePassword(ctx, "testuser", password))
70}
71
72func TestService_ValidatePassword_WrongPassword(t *testing.T) {
73 hash, err := bcrypt.GenerateFromPassword([]byte("correctpassword"), bcrypt.DefaultCost)
74 require.NoError(t, err)
75
76 svc := NewService(Config{
77 Enabled: true,
78 AuthType: domain.AuthTypePassword,
79 Username: "testuser",
80 PasswordHash: string(hash),
81 }, nil, zerowrap.Default())
82
83 ctx := testContext()
84 assert.False(t, svc.ValidatePassword(ctx, "testuser", "wrongpassword"))
85}
86
87func TestService_ValidatePassword_WrongUsername(t *testing.T) {
88 password := "testpassword123"
89 hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
90 require.NoError(t, err)
91
92 svc := NewService(Config{
93 Enabled: true,
94 AuthType: domain.AuthTypePassword,
95 Username: "testuser",
96 PasswordHash: string(hash),
97 }, nil, zerowrap.Default())
98
99 ctx := testContext()
100 assert.False(t, svc.ValidatePassword(ctx, "wronguser", password))
101}
102
103func TestService_GenerateToken_Success(t *testing.T) {
104 tokenStore := mocks.NewMockTokenStore(t)
105
106 // Capture the token to verify its properties
107 var capturedToken *domain.Token
108 var capturedJWT string
109
110 tokenStore.EXPECT().
111 SaveToken(
112 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
113 mock.MatchedBy(func(token *domain.Token) bool {
114 capturedToken = token
115 return token.Subject == "testsubject" &&
116 len(token.ID) > 0 &&
117 len(token.Scopes) == 2 &&
118 token.Scopes[0] == "push" &&
119 token.Scopes[1] == "pull" &&
120 !token.ExpiresAt.IsZero() &&
121 !token.Revoked
122 }),
123 mock.MatchedBy(func(jwt string) bool {
124 capturedJWT = jwt
125 // JWT should have 3 parts separated by dots
126 parts := 0
127 for _, c := range jwt {
128 if c == '.' {
129 parts++
130 }
131 }
132 return parts == 2 && len(jwt) > 50
133 }),
134 ).
135 Return(nil)
136
137 svc := NewService(Config{
138 Enabled: true,
139 AuthType: domain.AuthTypeToken,
140 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
141 }, tokenStore, zerowrap.Default())
142
143 ctx := testContext()
144 token, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
145
146 assert.NoError(t, err)
147 assert.NotEmpty(t, token)
148 assert.Equal(t, capturedJWT, token)
149 assert.NotNil(t, capturedToken)
150 assert.WithinDuration(t, time.Now().Add(time.Hour), capturedToken.ExpiresAt, 5*time.Second)
151}
152
153func TestService_GenerateToken_NeverExpires(t *testing.T) {
154 tokenStore := mocks.NewMockTokenStore(t)
155
156 tokenStore.EXPECT().
157 SaveToken(
158 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
159 mock.MatchedBy(func(token *domain.Token) bool {
160 return token.Subject == "ci-bot" &&
161 token.ExpiresAt.IsZero() // Never expires
162 }),
163 mock.MatchedBy(func(jwt string) bool {
164 return len(jwt) > 50
165 }),
166 ).
167 Return(nil)
168
169 svc := NewService(Config{
170 Enabled: true,
171 AuthType: domain.AuthTypeToken,
172 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
173 }, tokenStore, zerowrap.Default())
174
175 ctx := testContext()
176 token, err := svc.GenerateToken(ctx, "ci-bot", []string{"push", "pull"}, 0)
177
178 assert.NoError(t, err)
179 assert.NotEmpty(t, token)
180}
181
182func TestService_GenerateAccessToken_NoStorage(t *testing.T) {
183 // Access tokens should NOT be stored - this is the fix for the 401 bug
184 tokenStore := mocks.NewMockTokenStore(t)
185 // No SaveToken expectation - it should NOT be called
186
187 svc := NewService(Config{
188 Enabled: true,
189 AuthType: domain.AuthTypeToken,
190 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
191 }, tokenStore, zerowrap.Default())
192
193 ctx := testContext()
194 token, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:push,pull"}, 5*time.Minute)
195
196 assert.NoError(t, err)
197 assert.NotEmpty(t, token)
198
199 // Verify the token is valid and has correct claims
200 claims, err := svc.ValidateToken(ctx, token)
201 assert.NoError(t, err)
202 assert.Equal(t, "testuser", claims.Subject)
203 assert.Contains(t, claims.Scopes, "repository:myrepo:push,pull")
204 // Access token should have exactly 5 min expiry (allow 2 sec tolerance for test execution)
205 assert.InDelta(t, 300, claims.ExpiresAt-claims.IssuedAt, 2)
206}
207
208func TestService_GenerateAccessToken_RejectsInvalidExpiry(t *testing.T) {
209 tokenStore := mocks.NewMockTokenStore(t)
210
211 svc := NewService(Config{
212 Enabled: true,
213 AuthType: domain.AuthTypeToken,
214 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
215 }, tokenStore, zerowrap.Default())
216
217 ctx := testContext()
218
219 // Zero expiry should be rejected
220 _, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 0)
221 assert.Error(t, err)
222 assert.Contains(t, err.Error(), "expiry must be positive")
223
224 // Negative expiry should be rejected
225 _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, -time.Minute)
226 assert.Error(t, err)
227 assert.Contains(t, err.Error(), "expiry must be positive")
228
229 // Expiry exceeding max should be rejected
230 _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 10*time.Minute)
231 assert.Error(t, err)
232 assert.Contains(t, err.Error(), "exceeds maximum")
233}
234
235func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) {
236 // Tokens with expiry > 5 minutes must exist in store
237 tokenStore := mocks.NewMockTokenStore(t)
238
239 svc := NewService(Config{
240 Enabled: true,
241 AuthType: domain.AuthTypeToken,
242 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
243 }, tokenStore, zerowrap.Default())
244
245 ctx := testContext()
246
247 // Generate a long-lived token (stored)
248 var capturedToken *domain.Token
249 tokenStore.EXPECT().
250 SaveToken(mock.Anything, mock.Anything, mock.Anything).
251 Run(func(_ context.Context, t *domain.Token, _ string) {
252 capturedToken = t
253 }).
254 Return(nil)
255
256 token, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, time.Hour)
257 assert.NoError(t, err)
258
259 // Validation should call GetToken and IsRevoked
260 tokenStore.EXPECT().
261 GetToken(mock.Anything, "testuser").
262 Return(token, capturedToken, nil)
263 tokenStore.EXPECT().
264 IsRevoked(mock.Anything, capturedToken.ID).
265 Return(false, nil)
266
267 claims, err := svc.ValidateToken(ctx, token)
268 assert.NoError(t, err)
269 assert.Equal(t, "testuser", claims.Subject)
270}
271
272func TestService_ValidateToken_NeverExpiringRequiresStore(t *testing.T) {
273 // Tokens with ExpiresAt = 0 (never expires) must exist in store
274 tokenStore := mocks.NewMockTokenStore(t)
275
276 svc := NewService(Config{
277 Enabled: true,
278 AuthType: domain.AuthTypeToken,
279 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
280 }, tokenStore, zerowrap.Default())
281
282 ctx := testContext()
283
284 // Generate a never-expiring token (stored)
285 var capturedToken *domain.Token
286 tokenStore.EXPECT().
287 SaveToken(mock.Anything, mock.Anything, mock.Anything).
288 Run(func(_ context.Context, t *domain.Token, _ string) {
289 capturedToken = t
290 }).
291 Return(nil)
292
293 token, err := svc.GenerateToken(ctx, "ci-bot", []string{"push", "pull"}, 0)
294 assert.NoError(t, err)
295
296 // Validation should call GetToken and IsRevoked (not bypass store)
297 tokenStore.EXPECT().
298 GetToken(mock.Anything, "ci-bot").
299 Return(token, capturedToken, nil)
300 tokenStore.EXPECT().
301 IsRevoked(mock.Anything, capturedToken.ID).
302 Return(false, nil)
303
304 claims, err := svc.ValidateToken(ctx, token)
305 assert.NoError(t, err)
306 assert.Equal(t, "ci-bot", claims.Subject)
307 assert.Equal(t, int64(0), claims.ExpiresAt) // Never expires
308}
309
310func TestService_ValidateToken_Success(t *testing.T) {
311 tokenStore := mocks.NewMockTokenStore(t)
312
313 svc := NewService(Config{
314 Enabled: true,
315 AuthType: domain.AuthTypeToken,
316 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
317 }, tokenStore, zerowrap.Default())
318
319 // Capture the token ID when saving
320 var savedTokenID string
321 tokenStore.EXPECT().
322 SaveToken(
323 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
324 mock.MatchedBy(func(token *domain.Token) bool {
325 savedTokenID = token.ID
326 return token.Subject == "testsubject"
327 }),
328 mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
329 ).
330 Return(nil)
331
332 ctx := testContext()
333 tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
334 require.NoError(t, err)
335
336 // Expect GetToken to be called to verify token exists
337 tokenStore.EXPECT().
338 GetToken(
339 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
340 "testsubject",
341 ).
342 Return(tokenStr, &domain.Token{
343 ID: savedTokenID,
344 Subject: "testsubject",
345 Scopes: []string{"push", "pull"},
346 }, nil)
347
348 // Expect IsRevoked to be called with the same token ID
349 tokenStore.EXPECT().
350 IsRevoked(
351 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
352 mock.MatchedBy(func(tokenID string) bool {
353 return tokenID == savedTokenID
354 }),
355 ).
356 Return(false, nil)
357
358 claims, err := svc.ValidateToken(ctx, tokenStr)
359
360 assert.NoError(t, err)
361 assert.NotNil(t, claims)
362 assert.Equal(t, "testsubject", claims.Subject)
363 assert.Equal(t, savedTokenID, claims.ID)
364 assert.Equal(t, TokenIssuer, claims.Issuer)
365 assert.Contains(t, claims.Scopes, "push")
366 assert.Contains(t, claims.Scopes, "pull")
367}
368
369func TestService_ValidateToken_Revoked(t *testing.T) {
370 tokenStore := mocks.NewMockTokenStore(t)
371
372 svc := NewService(Config{
373 Enabled: true,
374 AuthType: domain.AuthTypeToken,
375 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
376 }, tokenStore, zerowrap.Default())
377
378 var savedTokenID string
379 tokenStore.EXPECT().
380 SaveToken(
381 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
382 mock.MatchedBy(func(token *domain.Token) bool {
383 savedTokenID = token.ID
384 return token.Subject == "testsubject"
385 }),
386 mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
387 ).
388 Return(nil)
389
390 ctx := testContext()
391 tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
392 require.NoError(t, err)
393
394 // Expect GetToken to be called to verify token exists
395 tokenStore.EXPECT().
396 GetToken(
397 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
398 "testsubject",
399 ).
400 Return(tokenStr, &domain.Token{
401 ID: savedTokenID,
402 Subject: "testsubject",
403 Scopes: []string{"push", "pull"},
404 }, nil)
405
406 // Token is revoked
407 tokenStore.EXPECT().
408 IsRevoked(
409 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
410 mock.MatchedBy(func(tokenID string) bool { return tokenID == savedTokenID }),
411 ).
412 Return(true, nil)
413
414 claims, err := svc.ValidateToken(ctx, tokenStr)
415
416 assert.Error(t, err)
417 assert.Nil(t, claims)
418 assert.ErrorIs(t, err, domain.ErrRevokedToken)
419}
420
421func TestService_ValidateToken_InvalidSignature(t *testing.T) {
422 tokenStore := mocks.NewMockTokenStore(t)
423
424 // Service with different secret for validation
425 svc := NewService(Config{
426 Enabled: true,
427 AuthType: domain.AuthTypeToken,
428 TokenSecret: []byte("different-secret-key"),
429 }, tokenStore, zerowrap.Default())
430
431 // Create a token with a different secret
432 otherSvc := NewService(Config{
433 Enabled: true,
434 AuthType: domain.AuthTypeToken,
435 TokenSecret: []byte("original-secret-key"),
436 }, tokenStore, zerowrap.Default())
437
438 tokenStore.EXPECT().
439 SaveToken(
440 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
441 mock.MatchedBy(func(token *domain.Token) bool { return token.Subject == "testsubject" }),
442 mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
443 ).
444 Return(nil)
445
446 ctx := testContext()
447 tokenStr, err := otherSvc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
448 require.NoError(t, err)
449
450 // Try to validate with wrong secret - should fail before checking revocation
451 claims, err := svc.ValidateToken(ctx, tokenStr)
452
453 assert.Error(t, err)
454 assert.Nil(t, claims)
455 assert.ErrorIs(t, err, domain.ErrInvalidToken)
456}
457
458func TestService_ValidateToken_Expired(t *testing.T) {
459 tokenStore := mocks.NewMockTokenStore(t)
460
461 svc := NewService(Config{
462 Enabled: true,
463 AuthType: domain.AuthTypeToken,
464 TokenSecret: []byte("test-secret-key"),
465 }, tokenStore, zerowrap.Default())
466
467 // Generate a token that expires immediately
468 tokenStore.EXPECT().
469 SaveToken(
470 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
471 mock.MatchedBy(func(token *domain.Token) bool { return token.Subject == "testsubject" }),
472 mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
473 ).
474 Return(nil)
475
476 ctx := testContext()
477 // Generate with very short expiry
478 tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push"}, time.Millisecond)
479 require.NoError(t, err)
480
481 // Wait for token to expire
482 time.Sleep(10 * time.Millisecond)
483
484 // JWT library handles expiry during parsing - returns invalid token error
485 // No IsRevoked call is made because parsing fails first
486 claims, err := svc.ValidateToken(ctx, tokenStr)
487
488 assert.Error(t, err)
489 assert.Nil(t, claims)
490 // JWT library returns invalid token for expired tokens during parsing
491 assert.ErrorIs(t, err, domain.ErrInvalidToken)
492}
493
494func TestService_ValidateToken_MalformedToken(t *testing.T) {
495 tokenStore := mocks.NewMockTokenStore(t)
496
497 svc := NewService(Config{
498 Enabled: true,
499 AuthType: domain.AuthTypeToken,
500 TokenSecret: []byte("test-secret-key"),
501 }, tokenStore, zerowrap.Default())
502
503 ctx := testContext()
504
505 // Test with various malformed tokens
506 malformedTokens := []string{
507 "",
508 "not-a-jwt",
509 "only.two.parts.here.extra",
510 "invalid.base64.token",
511 }
512
513 for _, token := range malformedTokens {
514 claims, err := svc.ValidateToken(ctx, token)
515 assert.Error(t, err, "expected error for token: %s", token)
516 assert.Nil(t, claims)
517 assert.ErrorIs(t, err, domain.ErrInvalidToken)
518 }
519}
520
521func TestService_RevokeToken(t *testing.T) {
522 tokenStore := mocks.NewMockTokenStore(t)
523
524 tokenID := "550e8400-e29b-41d4-a716-446655440000"
525
526 tokenStore.EXPECT().
527 Revoke(
528 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
529 tokenID,
530 ).
531 Return(nil)
532
533 svc := NewService(Config{
534 Enabled: true,
535 AuthType: domain.AuthTypeToken,
536 }, tokenStore, zerowrap.Default())
537
538 ctx := testContext()
539 err := svc.RevokeToken(ctx, tokenID)
540
541 assert.NoError(t, err)
542}
543
544func TestService_ListTokens(t *testing.T) {
545 tokenStore := mocks.NewMockTokenStore(t)
546
547 expectedTokens := []domain.Token{
548 {
549 ID: "token-1",
550 Subject: "user1",
551 Scopes: []string{"push", "pull"},
552 IssuedAt: time.Now().Add(-24 * time.Hour),
553 ExpiresAt: time.Now().Add(24 * time.Hour),
554 Revoked: false,
555 },
556 {
557 ID: "token-2",
558 Subject: "ci-bot",
559 Scopes: []string{"push", "pull"},
560 IssuedAt: time.Now().Add(-48 * time.Hour),
561 ExpiresAt: time.Time{}, // Never expires
562 Revoked: false,
563 },
564 }
565
566 tokenStore.EXPECT().
567 ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
568 Return(expectedTokens, nil)
569
570 svc := NewService(Config{
571 Enabled: true,
572 AuthType: domain.AuthTypeToken,
573 }, tokenStore, zerowrap.Default())
574
575 ctx := testContext()
576 tokens, err := svc.ListTokens(ctx)
577
578 assert.NoError(t, err)
579 assert.Len(t, tokens, 2)
580 assert.Equal(t, "user1", tokens[0].Subject)
581 assert.Equal(t, "ci-bot", tokens[1].Subject)
582 assert.True(t, tokens[1].ExpiresAt.IsZero(), "CI bot token should never expire")
583}
584
585func TestService_GeneratePasswordHash(t *testing.T) {
586 svc := NewService(Config{}, nil, zerowrap.Default())
587
588 password := "securePassword123!"
589 hash, err := svc.GeneratePasswordHash(password)
590
591 assert.NoError(t, err)
592 assert.NotEmpty(t, hash)
593 assert.NotEqual(t, password, hash, "hash should not equal plain password")
594
595 // Verify the hash works with bcrypt
596 err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
597 assert.NoError(t, err)
598
599 // Verify wrong password fails
600 err = bcrypt.CompareHashAndPassword([]byte(hash), []byte("wrongpassword"))
601 assert.Error(t, err)
602}
603
604func TestService_GeneratePasswordHash_DifferentHashesForSamePassword(t *testing.T) {
605 svc := NewService(Config{}, nil, zerowrap.Default())
606
607 password := "testpassword"
608 hash1, err := svc.GeneratePasswordHash(password)
609 require.NoError(t, err)
610
611 hash2, err := svc.GeneratePasswordHash(password)
612 require.NoError(t, err)
613
614 // Bcrypt should generate different hashes each time due to salt
615 assert.NotEqual(t, hash1, hash2, "bcrypt should generate unique hashes")
616
617 // But both should validate correctly
618 assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password)))
619 assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password)))
620}