1package auth
2
3import (
4 "context"
5 "errors"
6 "testing"
7 "time"
8
9 "github.com/bnema/zerowrap"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/mock"
12 "github.com/stretchr/testify/require"
13 "golang.org/x/crypto/bcrypt"
14
15 "github.com/bnema/gordon/internal/boundaries/out/mocks"
16 "github.com/bnema/gordon/internal/domain"
17)
18
19func testContext() context.Context {
20 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
21}
22
23func TestService_GetAuthType(t *testing.T) {
24 tests := []struct {
25 name string
26 authType domain.AuthType
27 }{
28 {"password type", domain.AuthTypePassword},
29 {"token type", domain.AuthTypeToken},
30 }
31
32 for _, tt := range tests {
33 t.Run(tt.name, func(t *testing.T) {
34 svc := NewService(Config{AuthType: tt.authType}, nil, zerowrap.Default())
35 assert.Equal(t, tt.authType, svc.GetAuthType())
36 })
37 }
38}
39
40func TestService_IsEnabled(t *testing.T) {
41 tests := []struct {
42 name string
43 enabled bool
44 }{
45 {"enabled", true},
46 {"disabled", false},
47 }
48
49 for _, tt := range tests {
50 t.Run(tt.name, func(t *testing.T) {
51 svc := NewService(Config{Enabled: tt.enabled}, nil, zerowrap.Default())
52 assert.Equal(t, tt.enabled, svc.IsEnabled())
53 })
54 }
55}
56
57func TestService_ValidatePassword_Success(t *testing.T) {
58 password := "testpassword123"
59 hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
60 require.NoError(t, err)
61
62 svc := NewService(Config{
63 Enabled: true,
64 AuthType: domain.AuthTypePassword,
65 Username: "testuser",
66 PasswordHash: string(hash),
67 }, nil, zerowrap.Default())
68
69 ctx := testContext()
70 assert.True(t, svc.ValidatePassword(ctx, "testuser", password))
71}
72
73func TestService_ValidatePassword_WrongPassword(t *testing.T) {
74 hash, err := bcrypt.GenerateFromPassword([]byte("correctpassword"), bcrypt.DefaultCost)
75 require.NoError(t, err)
76
77 svc := NewService(Config{
78 Enabled: true,
79 AuthType: domain.AuthTypePassword,
80 Username: "testuser",
81 PasswordHash: string(hash),
82 }, nil, zerowrap.Default())
83
84 ctx := testContext()
85 assert.False(t, svc.ValidatePassword(ctx, "testuser", "wrongpassword"))
86}
87
88func TestService_ValidatePassword_WrongUsername(t *testing.T) {
89 password := "testpassword123"
90 hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
91 require.NoError(t, err)
92
93 svc := NewService(Config{
94 Enabled: true,
95 AuthType: domain.AuthTypePassword,
96 Username: "testuser",
97 PasswordHash: string(hash),
98 }, nil, zerowrap.Default())
99
100 ctx := testContext()
101 assert.False(t, svc.ValidatePassword(ctx, "wronguser", password))
102}
103
104func TestService_GenerateToken_Success(t *testing.T) {
105 tokenStore := mocks.NewMockTokenStore(t)
106
107 // Capture the token to verify its properties
108 var capturedToken *domain.Token
109 var capturedJWT string
110
111 tokenStore.EXPECT().
112 SaveToken(
113 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
114 mock.MatchedBy(func(token *domain.Token) bool {
115 capturedToken = token
116 return token.Subject == "testsubject" &&
117 len(token.ID) > 0 &&
118 len(token.Scopes) == 2 &&
119 token.Scopes[0] == "push" &&
120 token.Scopes[1] == "pull" &&
121 !token.ExpiresAt.IsZero() &&
122 !token.Revoked
123 }),
124 mock.MatchedBy(func(jwt string) bool {
125 capturedJWT = jwt
126 // JWT should have 3 parts separated by dots
127 parts := 0
128 for _, c := range jwt {
129 if c == '.' {
130 parts++
131 }
132 }
133 return parts == 2 && len(jwt) > 50
134 }),
135 ).
136 Return(nil)
137
138 svc := NewService(Config{
139 Enabled: true,
140 AuthType: domain.AuthTypeToken,
141 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
142 }, tokenStore, zerowrap.Default())
143
144 ctx := testContext()
145 token, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
146
147 assert.NoError(t, err)
148 assert.NotEmpty(t, token)
149 assert.Equal(t, capturedJWT, token)
150 assert.NotNil(t, capturedToken)
151 assert.WithinDuration(t, time.Now().Add(time.Hour), capturedToken.ExpiresAt, 5*time.Second)
152}
153
154func TestService_GenerateToken_NeverExpires(t *testing.T) {
155 tokenStore := mocks.NewMockTokenStore(t)
156
157 tokenStore.EXPECT().
158 SaveToken(
159 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
160 mock.MatchedBy(func(token *domain.Token) bool {
161 return token.Subject == "ci-bot" &&
162 token.ExpiresAt.IsZero() // Never expires
163 }),
164 mock.MatchedBy(func(jwt string) bool {
165 return len(jwt) > 50
166 }),
167 ).
168 Return(nil)
169
170 svc := NewService(Config{
171 Enabled: true,
172 AuthType: domain.AuthTypeToken,
173 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
174 }, tokenStore, zerowrap.Default())
175
176 ctx := testContext()
177 token, err := svc.GenerateToken(ctx, "ci-bot", []string{"push", "pull"}, 0)
178
179 assert.NoError(t, err)
180 assert.NotEmpty(t, token)
181}
182
183func TestService_GenerateAccessToken_NoStorage(t *testing.T) {
184 // Access tokens should NOT be stored - this is the fix for the 401 bug
185 tokenStore := mocks.NewMockTokenStore(t)
186 // No SaveToken expectation - it should NOT be called
187
188 svc := NewService(Config{
189 Enabled: true,
190 AuthType: domain.AuthTypeToken,
191 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
192 }, tokenStore, zerowrap.Default())
193
194 ctx := testContext()
195 token, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:push,pull"}, 5*time.Minute)
196
197 assert.NoError(t, err)
198 assert.NotEmpty(t, token)
199
200 // Verify the token is valid and has correct claims
201 claims, err := svc.ValidateToken(ctx, token)
202 assert.NoError(t, err)
203 assert.Equal(t, "testuser", claims.Subject)
204 assert.Contains(t, claims.Scopes, "repository:myrepo:push,pull")
205 // Access token should have exactly 5 min expiry (allow 2 sec tolerance for test execution)
206 assert.InDelta(t, 300, claims.ExpiresAt-claims.IssuedAt, 2)
207}
208
209func TestService_GenerateAccessToken_RejectsInvalidExpiry(t *testing.T) {
210 tokenStore := mocks.NewMockTokenStore(t)
211
212 svc := NewService(Config{
213 Enabled: true,
214 AuthType: domain.AuthTypeToken,
215 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
216 }, tokenStore, zerowrap.Default())
217
218 ctx := testContext()
219
220 // Zero expiry should be rejected
221 _, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 0)
222 assert.Error(t, err)
223 assert.Contains(t, err.Error(), "expiry must be positive")
224
225 // Negative expiry should be rejected
226 _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, -time.Minute)
227 assert.Error(t, err)
228 assert.Contains(t, err.Error(), "expiry must be positive")
229
230 // Expiry exceeding max should be rejected
231 _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 10*time.Minute)
232 assert.Error(t, err)
233 assert.Contains(t, err.Error(), "exceeds maximum")
234}
235
236func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) {
237 // Tokens with expiry > 5 minutes must exist in store
238 tokenStore := mocks.NewMockTokenStore(t)
239
240 svc := NewService(Config{
241 Enabled: true,
242 AuthType: domain.AuthTypeToken,
243 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
244 }, tokenStore, zerowrap.Default())
245
246 ctx := testContext()
247
248 // Generate a long-lived token (stored)
249 var capturedToken *domain.Token
250 tokenStore.EXPECT().
251 SaveToken(mock.Anything, mock.Anything, mock.Anything).
252 Run(func(_ context.Context, t *domain.Token, _ string) {
253 capturedToken = t
254 }).
255 Return(nil)
256
257 token, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, time.Hour)
258 assert.NoError(t, err)
259
260 // Validation should call GetToken and IsRevoked
261 tokenStore.EXPECT().
262 GetToken(mock.Anything, "testuser").
263 Return(token, capturedToken, nil)
264 tokenStore.EXPECT().
265 IsRevoked(mock.Anything, capturedToken.ID).
266 Return(false, nil)
267
268 claims, err := svc.ValidateToken(ctx, token)
269 assert.NoError(t, err)
270 assert.Equal(t, "testuser", claims.Subject)
271}
272
273func TestService_ValidateToken_NeverExpiringRequiresStore(t *testing.T) {
274 // Tokens with ExpiresAt = 0 (never expires) must exist in store
275 tokenStore := mocks.NewMockTokenStore(t)
276
277 svc := NewService(Config{
278 Enabled: true,
279 AuthType: domain.AuthTypeToken,
280 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
281 }, tokenStore, zerowrap.Default())
282
283 ctx := testContext()
284
285 // Generate a never-expiring token (stored)
286 var capturedToken *domain.Token
287 tokenStore.EXPECT().
288 SaveToken(mock.Anything, mock.Anything, mock.Anything).
289 Run(func(_ context.Context, t *domain.Token, _ string) {
290 capturedToken = t
291 }).
292 Return(nil)
293
294 token, err := svc.GenerateToken(ctx, "ci-bot", []string{"push", "pull"}, 0)
295 assert.NoError(t, err)
296
297 // Validation should call GetToken and IsRevoked (not bypass store)
298 tokenStore.EXPECT().
299 GetToken(mock.Anything, "ci-bot").
300 Return(token, capturedToken, nil)
301 tokenStore.EXPECT().
302 IsRevoked(mock.Anything, capturedToken.ID).
303 Return(false, nil)
304
305 claims, err := svc.ValidateToken(ctx, token)
306 assert.NoError(t, err)
307 assert.Equal(t, "ci-bot", claims.Subject)
308 assert.Equal(t, int64(0), claims.ExpiresAt) // Never expires
309}
310
311func TestService_ValidateToken_Success(t *testing.T) {
312 tokenStore := mocks.NewMockTokenStore(t)
313
314 svc := NewService(Config{
315 Enabled: true,
316 AuthType: domain.AuthTypeToken,
317 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
318 }, tokenStore, zerowrap.Default())
319
320 // Capture the token ID when saving
321 var savedTokenID string
322 tokenStore.EXPECT().
323 SaveToken(
324 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
325 mock.MatchedBy(func(token *domain.Token) bool {
326 savedTokenID = token.ID
327 return token.Subject == "testsubject"
328 }),
329 mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
330 ).
331 Return(nil)
332
333 ctx := testContext()
334 tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
335 require.NoError(t, err)
336
337 // Expect GetToken to be called to verify token exists
338 tokenStore.EXPECT().
339 GetToken(
340 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
341 "testsubject",
342 ).
343 Return(tokenStr, &domain.Token{
344 ID: savedTokenID,
345 Subject: "testsubject",
346 Scopes: []string{"push", "pull"},
347 }, nil)
348
349 // Expect IsRevoked to be called with the same token ID
350 tokenStore.EXPECT().
351 IsRevoked(
352 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
353 mock.MatchedBy(func(tokenID string) bool {
354 return tokenID == savedTokenID
355 }),
356 ).
357 Return(false, nil)
358
359 claims, err := svc.ValidateToken(ctx, tokenStr)
360
361 assert.NoError(t, err)
362 assert.NotNil(t, claims)
363 assert.Equal(t, "testsubject", claims.Subject)
364 assert.Equal(t, savedTokenID, claims.ID)
365 assert.Equal(t, TokenIssuer, claims.Issuer)
366 assert.Contains(t, claims.Scopes, "push")
367 assert.Contains(t, claims.Scopes, "pull")
368}
369
370func TestService_ValidateToken_Revoked(t *testing.T) {
371 tokenStore := mocks.NewMockTokenStore(t)
372
373 svc := NewService(Config{
374 Enabled: true,
375 AuthType: domain.AuthTypeToken,
376 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
377 }, tokenStore, zerowrap.Default())
378
379 var savedTokenID string
380 tokenStore.EXPECT().
381 SaveToken(
382 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
383 mock.MatchedBy(func(token *domain.Token) bool {
384 savedTokenID = token.ID
385 return token.Subject == "testsubject"
386 }),
387 mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
388 ).
389 Return(nil)
390
391 ctx := testContext()
392 tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
393 require.NoError(t, err)
394
395 // Expect GetToken to be called to verify token exists
396 tokenStore.EXPECT().
397 GetToken(
398 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
399 "testsubject",
400 ).
401 Return(tokenStr, &domain.Token{
402 ID: savedTokenID,
403 Subject: "testsubject",
404 Scopes: []string{"push", "pull"},
405 }, nil)
406
407 // Token is revoked
408 tokenStore.EXPECT().
409 IsRevoked(
410 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
411 mock.MatchedBy(func(tokenID string) bool { return tokenID == savedTokenID }),
412 ).
413 Return(true, nil)
414
415 claims, err := svc.ValidateToken(ctx, tokenStr)
416
417 assert.Error(t, err)
418 assert.Nil(t, claims)
419 assert.ErrorIs(t, err, domain.ErrRevokedToken)
420}
421
422func TestService_ValidateToken_InvalidSignature(t *testing.T) {
423 tokenStore := mocks.NewMockTokenStore(t)
424
425 // Service with different secret for validation
426 svc := NewService(Config{
427 Enabled: true,
428 AuthType: domain.AuthTypeToken,
429 TokenSecret: []byte("different-secret-key"),
430 }, tokenStore, zerowrap.Default())
431
432 // Create a token with a different secret
433 otherSvc := NewService(Config{
434 Enabled: true,
435 AuthType: domain.AuthTypeToken,
436 TokenSecret: []byte("original-secret-key"),
437 }, tokenStore, zerowrap.Default())
438
439 tokenStore.EXPECT().
440 SaveToken(
441 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
442 mock.MatchedBy(func(token *domain.Token) bool { return token.Subject == "testsubject" }),
443 mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
444 ).
445 Return(nil)
446
447 ctx := testContext()
448 tokenStr, err := otherSvc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
449 require.NoError(t, err)
450
451 // Try to validate with wrong secret - should fail before checking revocation
452 claims, err := svc.ValidateToken(ctx, tokenStr)
453
454 assert.Error(t, err)
455 assert.Nil(t, claims)
456 assert.ErrorIs(t, err, domain.ErrInvalidToken)
457}
458
459func TestService_ValidateToken_Expired(t *testing.T) {
460 tokenStore := mocks.NewMockTokenStore(t)
461
462 svc := NewService(Config{
463 Enabled: true,
464 AuthType: domain.AuthTypeToken,
465 TokenSecret: []byte("test-secret-key"),
466 }, tokenStore, zerowrap.Default())
467
468 // Generate a token that expires immediately
469 tokenStore.EXPECT().
470 SaveToken(
471 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
472 mock.MatchedBy(func(token *domain.Token) bool { return token.Subject == "testsubject" }),
473 mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
474 ).
475 Return(nil)
476
477 ctx := testContext()
478 // Generate with very short expiry
479 tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push"}, time.Millisecond)
480 require.NoError(t, err)
481
482 // Wait for token to expire
483 time.Sleep(10 * time.Millisecond)
484
485 // JWT library handles expiry during parsing - returns invalid token error
486 // No IsRevoked call is made because parsing fails first
487 claims, err := svc.ValidateToken(ctx, tokenStr)
488
489 assert.Error(t, err)
490 assert.Nil(t, claims)
491 // JWT library returns invalid token for expired tokens during parsing
492 assert.ErrorIs(t, err, domain.ErrInvalidToken)
493}
494
495func TestService_ValidateToken_MalformedToken(t *testing.T) {
496 tokenStore := mocks.NewMockTokenStore(t)
497
498 svc := NewService(Config{
499 Enabled: true,
500 AuthType: domain.AuthTypeToken,
501 TokenSecret: []byte("test-secret-key"),
502 }, tokenStore, zerowrap.Default())
503
504 ctx := testContext()
505
506 // Test with various malformed tokens
507 malformedTokens := []string{
508 "",
509 "not-a-jwt",
510 "only.two.parts.here.extra",
511 "invalid.base64.token",
512 }
513
514 for _, token := range malformedTokens {
515 claims, err := svc.ValidateToken(ctx, token)
516 assert.Error(t, err, "expected error for token: %s", token)
517 assert.Nil(t, claims)
518 assert.ErrorIs(t, err, domain.ErrInvalidToken)
519 }
520}
521
522func TestService_RevokeToken(t *testing.T) {
523 tokenStore := mocks.NewMockTokenStore(t)
524
525 tokenID := "550e8400-e29b-41d4-a716-446655440000"
526
527 tokenStore.EXPECT().
528 Revoke(
529 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
530 tokenID,
531 ).
532 Return(nil)
533
534 svc := NewService(Config{
535 Enabled: true,
536 AuthType: domain.AuthTypeToken,
537 }, tokenStore, zerowrap.Default())
538
539 ctx := testContext()
540 err := svc.RevokeToken(ctx, tokenID)
541
542 assert.NoError(t, err)
543}
544
545func TestService_RevokeAllTokens(t *testing.T) {
546 t.Run("revokes all active tokens", func(t *testing.T) {
547 tokenStore := mocks.NewMockTokenStore(t)
548
549 tokens := []domain.Token{
550 {ID: "token-1", Subject: "user1", Revoked: false},
551 {ID: "token-2", Subject: "user2", Revoked: false},
552 {ID: "token-3", Subject: "user3", Revoked: true}, // already revoked
553 }
554
555 tokenStore.EXPECT().
556 ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
557 Return(tokens, nil)
558
559 // Should only revoke the two non-revoked tokens
560 tokenStore.EXPECT().
561 Revoke(mock.Anything, "token-1").
562 Return(nil)
563 tokenStore.EXPECT().
564 Revoke(mock.Anything, "token-2").
565 Return(nil)
566
567 svc := NewService(Config{
568 Enabled: true,
569 AuthType: domain.AuthTypeToken,
570 }, tokenStore, zerowrap.Default())
571
572 ctx := testContext()
573 count, err := svc.RevokeAllTokens(ctx)
574
575 require.NoError(t, err)
576 assert.Equal(t, 2, count)
577 })
578
579 t.Run("returns zero when no active tokens", func(t *testing.T) {
580 tokenStore := mocks.NewMockTokenStore(t)
581
582 tokens := []domain.Token{
583 {ID: "token-1", Subject: "user1", Revoked: true},
584 }
585
586 tokenStore.EXPECT().
587 ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
588 Return(tokens, nil)
589
590 svc := NewService(Config{
591 Enabled: true,
592 AuthType: domain.AuthTypeToken,
593 }, tokenStore, zerowrap.Default())
594
595 ctx := testContext()
596 count, err := svc.RevokeAllTokens(ctx)
597
598 require.NoError(t, err)
599 assert.Equal(t, 0, count)
600 })
601
602 t.Run("returns zero when no tokens exist", func(t *testing.T) {
603 tokenStore := mocks.NewMockTokenStore(t)
604
605 tokenStore.EXPECT().
606 ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
607 Return([]domain.Token{}, nil)
608
609 svc := NewService(Config{
610 Enabled: true,
611 AuthType: domain.AuthTypeToken,
612 }, tokenStore, zerowrap.Default())
613
614 ctx := testContext()
615 count, err := svc.RevokeAllTokens(ctx)
616
617 require.NoError(t, err)
618 assert.Equal(t, 0, count)
619 })
620
621 t.Run("returns error when ListTokens fails", func(t *testing.T) {
622 tokenStore := mocks.NewMockTokenStore(t)
623
624 tokenStore.EXPECT().
625 ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
626 Return(nil, errors.New("storage unavailable"))
627
628 svc := NewService(Config{
629 Enabled: true,
630 AuthType: domain.AuthTypeToken,
631 }, tokenStore, zerowrap.Default())
632
633 ctx := testContext()
634 count, err := svc.RevokeAllTokens(ctx)
635
636 require.Error(t, err)
637 assert.Contains(t, err.Error(), "failed to list tokens")
638 assert.Equal(t, 0, count)
639 })
640
641 t.Run("continues on partial revoke failure", func(t *testing.T) {
642 tokenStore := mocks.NewMockTokenStore(t)
643
644 tokens := []domain.Token{
645 {ID: "token-1", Subject: "user1", Revoked: false},
646 {ID: "token-2", Subject: "user2", Revoked: false},
647 {ID: "token-3", Subject: "user3", Revoked: false},
648 }
649
650 tokenStore.EXPECT().
651 ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
652 Return(tokens, nil)
653
654 // First token succeeds
655 tokenStore.EXPECT().
656 Revoke(mock.Anything, "token-1").
657 Return(nil)
658 // Second token fails
659 tokenStore.EXPECT().
660 Revoke(mock.Anything, "token-2").
661 Return(errors.New("revoke failed"))
662 // Third token succeeds
663 tokenStore.EXPECT().
664 Revoke(mock.Anything, "token-3").
665 Return(nil)
666
667 svc := NewService(Config{
668 Enabled: true,
669 AuthType: domain.AuthTypeToken,
670 }, tokenStore, zerowrap.Default())
671
672 ctx := testContext()
673 count, err := svc.RevokeAllTokens(ctx)
674
675 // Should not return error, but count only successful revokes
676 require.NoError(t, err)
677 assert.Equal(t, 2, count)
678 })
679}
680
681func TestService_ListTokens(t *testing.T) {
682 tokenStore := mocks.NewMockTokenStore(t)
683
684 expectedTokens := []domain.Token{
685 {
686 ID: "token-1",
687 Subject: "user1",
688 Scopes: []string{"push", "pull"},
689 IssuedAt: time.Now().Add(-24 * time.Hour),
690 ExpiresAt: time.Now().Add(24 * time.Hour),
691 Revoked: false,
692 },
693 {
694 ID: "token-2",
695 Subject: "ci-bot",
696 Scopes: []string{"push", "pull"},
697 IssuedAt: time.Now().Add(-48 * time.Hour),
698 ExpiresAt: time.Time{}, // Never expires
699 Revoked: false,
700 },
701 }
702
703 tokenStore.EXPECT().
704 ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
705 Return(expectedTokens, nil)
706
707 svc := NewService(Config{
708 Enabled: true,
709 AuthType: domain.AuthTypeToken,
710 }, tokenStore, zerowrap.Default())
711
712 ctx := testContext()
713 tokens, err := svc.ListTokens(ctx)
714
715 assert.NoError(t, err)
716 assert.Len(t, tokens, 2)
717 assert.Equal(t, "user1", tokens[0].Subject)
718 assert.Equal(t, "ci-bot", tokens[1].Subject)
719 assert.True(t, tokens[1].ExpiresAt.IsZero(), "CI bot token should never expire")
720}
721
722func TestService_GeneratePasswordHash(t *testing.T) {
723 svc := NewService(Config{}, nil, zerowrap.Default())
724
725 password := "securePassword123!"
726 hash, err := svc.GeneratePasswordHash(password)
727
728 assert.NoError(t, err)
729 assert.NotEmpty(t, hash)
730 assert.NotEqual(t, password, hash, "hash should not equal plain password")
731
732 // Verify the hash works with bcrypt
733 err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
734 assert.NoError(t, err)
735
736 // Verify wrong password fails
737 err = bcrypt.CompareHashAndPassword([]byte(hash), []byte("wrongpassword"))
738 assert.Error(t, err)
739}
740
741func TestService_GeneratePasswordHash_DifferentHashesForSamePassword(t *testing.T) {
742 svc := NewService(Config{}, nil, zerowrap.Default())
743
744 password := "testpassword"
745 hash1, err := svc.GeneratePasswordHash(password)
746 require.NoError(t, err)
747
748 hash2, err := svc.GeneratePasswordHash(password)
749 require.NoError(t, err)
750
751 // Bcrypt should generate different hashes each time due to salt
752 assert.NotEqual(t, hash1, hash2, "bcrypt should generate unique hashes")
753
754 // But both should validate correctly
755 assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password)))
756 assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password)))
757}
758
759func TestService_GenerateToken_HasNbfClaim(t *testing.T) {
760 // Test that generated tokens include the nbf (not-before) claim
761 // This is a security requirement per SEC-MED-006
762 tokenStore := mocks.NewMockTokenStore(t)
763
764 tokenStore.EXPECT().
765 SaveToken(mock.Anything, mock.Anything, mock.Anything).
766 Return(nil)
767
768 svc := NewService(Config{
769 Enabled: true,
770 AuthType: domain.AuthTypeToken,
771 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
772 }, tokenStore, zerowrap.Default())
773
774 ctx := testContext()
775 tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push"}, time.Hour)
776 require.NoError(t, err)
777
778 // Parse the token to verify nbf claim
779 claims, err := svc.parseTokenClaims(tokenStr)
780 require.NoError(t, err)
781
782 // Verify nbf claim exists and is valid
783 nbf, ok := claims["nbf"].(float64)
784 require.True(t, ok, "nbf claim should exist and be a number")
785
786 iat, ok := claims["iat"].(float64)
787 require.True(t, ok, "iat claim should exist")
788
789 // nbf should equal iat for immediate validity
790 assert.Equal(t, iat, nbf, "nbf should equal iat")
791 assert.InDelta(t, time.Now().Unix(), int64(nbf), 5, "nbf should be current time")
792}
793
794func TestService_GenerateAccessToken_HasNbfClaim(t *testing.T) {
795 // Test that access tokens also include the nbf claim
796 svc := NewService(Config{
797 Enabled: true,
798 AuthType: domain.AuthTypeToken,
799 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
800 }, nil, zerowrap.Default())
801
802 ctx := testContext()
803 tokenStr, err := svc.GenerateAccessToken(ctx, "testsubject", []string{"pull"}, time.Minute)
804 require.NoError(t, err)
805
806 // Parse the token to verify nbf claim
807 claims, err := svc.parseTokenClaims(tokenStr)
808 require.NoError(t, err)
809
810 // Verify nbf claim exists
811 nbf, ok := claims["nbf"].(float64)
812 require.True(t, ok, "nbf claim should exist and be a number")
813
814 iat, ok := claims["iat"].(float64)
815 require.True(t, ok, "iat claim should exist")
816
817 assert.Equal(t, iat, nbf, "nbf should equal iat for access tokens")
818}