v2.10.2 705579a

service_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close