v2.30.0-pre 7341e0e

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/golang-jwt/jwt/v5"
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/mock"
13	"github.com/stretchr/testify/require"
14	"golang.org/x/crypto/bcrypt"
15
16	"github.com/bnema/gordon/internal/boundaries/out/mocks"
17	"github.com/bnema/gordon/internal/domain"
18)
19
20func testContext() context.Context {
21	return zerowrap.WithCtx(context.Background(), zerowrap.Default())
22}
23
24func TestService_GetAuthType(t *testing.T) {
25	tests := []struct {
26		name     string
27		authType domain.AuthType
28	}{
29		{"password type", domain.AuthTypePassword},
30		{"token type", domain.AuthTypeToken},
31	}
32
33	for _, tt := range tests {
34		t.Run(tt.name, func(t *testing.T) {
35			svc := NewService(Config{AuthType: tt.authType}, nil, zerowrap.Default())
36			assert.Equal(t, tt.authType, svc.GetAuthType())
37		})
38	}
39}
40
41func TestService_IsEnabled(t *testing.T) {
42	tests := []struct {
43		name    string
44		enabled bool
45	}{
46		{"enabled", true},
47		{"disabled", false},
48	}
49
50	for _, tt := range tests {
51		t.Run(tt.name, func(t *testing.T) {
52			svc := NewService(Config{Enabled: tt.enabled}, nil, zerowrap.Default())
53			assert.Equal(t, tt.enabled, svc.IsEnabled())
54		})
55	}
56}
57
58func TestService_ValidatePassword_Success(t *testing.T) {
59	password := "testpassword123"
60	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
61	require.NoError(t, err)
62
63	svc := NewService(Config{
64		Enabled:      true,
65		AuthType:     domain.AuthTypePassword,
66		Username:     "testuser",
67		PasswordHash: string(hash),
68	}, nil, zerowrap.Default())
69
70	ctx := testContext()
71	assert.True(t, svc.ValidatePassword(ctx, "testuser", password))
72}
73
74func TestService_ValidatePassword_WrongPassword(t *testing.T) {
75	hash, err := bcrypt.GenerateFromPassword([]byte("correctpassword"), bcrypt.DefaultCost)
76	require.NoError(t, err)
77
78	svc := NewService(Config{
79		Enabled:      true,
80		AuthType:     domain.AuthTypePassword,
81		Username:     "testuser",
82		PasswordHash: string(hash),
83	}, nil, zerowrap.Default())
84
85	ctx := testContext()
86	assert.False(t, svc.ValidatePassword(ctx, "testuser", "wrongpassword"))
87}
88
89func TestService_ValidatePassword_WrongUsername(t *testing.T) {
90	password := "testpassword123"
91	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
92	require.NoError(t, err)
93
94	svc := NewService(Config{
95		Enabled:      true,
96		AuthType:     domain.AuthTypePassword,
97		Username:     "testuser",
98		PasswordHash: string(hash),
99	}, nil, zerowrap.Default())
100
101	ctx := testContext()
102	assert.False(t, svc.ValidatePassword(ctx, "wronguser", password))
103}
104
105func TestService_GenerateToken_Success(t *testing.T) {
106	tokenStore := mocks.NewMockTokenStore(t)
107
108	// Capture the token to verify its properties
109	var capturedToken *domain.Token
110	var capturedJWT string
111
112	tokenStore.EXPECT().
113		SaveToken(
114			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
115			mock.MatchedBy(func(token *domain.Token) bool {
116				capturedToken = token
117				return token.Subject == "testsubject" &&
118					len(token.ID) > 0 &&
119					len(token.Scopes) == 2 &&
120					token.Scopes[0] == "push" &&
121					token.Scopes[1] == "pull" &&
122					!token.ExpiresAt.IsZero() &&
123					!token.Revoked
124			}),
125			mock.MatchedBy(func(jwt string) bool {
126				capturedJWT = jwt
127				// JWT should have 3 parts separated by dots
128				parts := 0
129				for _, c := range jwt {
130					if c == '.' {
131						parts++
132					}
133				}
134				return parts == 2 && len(jwt) > 50
135			}),
136		).
137		Return(nil)
138
139	svc := NewService(Config{
140		Enabled:     true,
141		AuthType:    domain.AuthTypeToken,
142		TokenSecret: []byte("test-secret-key-for-jwt-signing"),
143	}, tokenStore, zerowrap.Default())
144
145	ctx := testContext()
146	token, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
147
148	assert.NoError(t, err)
149	assert.NotEmpty(t, token)
150	assert.Equal(t, capturedJWT, token)
151	assert.NotNil(t, capturedToken)
152	assert.WithinDuration(t, time.Now().Add(time.Hour), capturedToken.ExpiresAt, 5*time.Second)
153}
154
155func TestService_GenerateToken_NeverExpires(t *testing.T) {
156	tokenStore := mocks.NewMockTokenStore(t)
157
158	tokenStore.EXPECT().
159		SaveToken(
160			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
161			mock.MatchedBy(func(token *domain.Token) bool {
162				return token.Subject == "ci-bot" &&
163					token.ExpiresAt.IsZero() // Never expires
164			}),
165			mock.MatchedBy(func(jwt string) bool {
166				return len(jwt) > 50
167			}),
168		).
169		Return(nil)
170
171	svc := NewService(Config{
172		Enabled:     true,
173		AuthType:    domain.AuthTypeToken,
174		TokenSecret: []byte("test-secret-key-for-jwt-signing"),
175	}, tokenStore, zerowrap.Default())
176
177	ctx := testContext()
178	token, err := svc.GenerateToken(ctx, "ci-bot", []string{"push", "pull"}, 0)
179
180	assert.NoError(t, err)
181	assert.NotEmpty(t, token)
182}
183
184func TestService_GenerateAccessToken_NoStorage(t *testing.T) {
185	// Access tokens should NOT be stored - this is the fix for the 401 bug
186	tokenStore := mocks.NewMockTokenStore(t)
187	// No SaveToken expectation - it should NOT be called
188
189	svc := NewService(Config{
190		Enabled:     true,
191		AuthType:    domain.AuthTypeToken,
192		TokenSecret: []byte("test-secret-key-for-jwt-signing"),
193	}, tokenStore, zerowrap.Default())
194
195	ctx := testContext()
196	token, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:push,pull"}, 5*time.Minute)
197
198	assert.NoError(t, err)
199	assert.NotEmpty(t, token)
200
201	// Verify the token is valid and has correct claims
202	claims, err := svc.ValidateToken(ctx, token)
203	assert.NoError(t, err)
204	assert.Equal(t, "testuser", claims.Subject)
205	assert.Contains(t, claims.Scopes, "repository:myrepo:push,pull")
206	// Access token should have exactly 5 min expiry (allow 2 sec tolerance for test execution)
207	assert.InDelta(t, 300, claims.ExpiresAt-claims.IssuedAt, 2)
208}
209
210func TestService_GenerateAccessToken_RejectsInvalidExpiry(t *testing.T) {
211	tokenStore := mocks.NewMockTokenStore(t)
212
213	svc := NewService(Config{
214		Enabled:     true,
215		AuthType:    domain.AuthTypeToken,
216		TokenSecret: []byte("test-secret-key-for-jwt-signing"),
217	}, tokenStore, zerowrap.Default())
218
219	ctx := testContext()
220
221	// Zero expiry should be rejected
222	_, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 0)
223	assert.Error(t, err)
224	assert.Contains(t, err.Error(), "expiry must be positive")
225
226	// Negative expiry should be rejected
227	_, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, -time.Minute)
228	assert.Error(t, err)
229	assert.Contains(t, err.Error(), "expiry must be positive")
230
231	// Expiry exceeding max should be rejected
232	_, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 10*time.Minute)
233	assert.Error(t, err)
234	assert.Contains(t, err.Error(), "exceeds maximum")
235}
236
237func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) {
238	// Tokens with expiry > 5 minutes must exist in store
239	tokenStore := mocks.NewMockTokenStore(t)
240
241	svc := NewService(Config{
242		Enabled:     true,
243		AuthType:    domain.AuthTypeToken,
244		TokenSecret: []byte("test-secret-key-for-jwt-signing"),
245	}, tokenStore, zerowrap.Default())
246
247	ctx := testContext()
248
249	// Generate a long-lived token (stored)
250	var capturedToken *domain.Token
251	tokenStore.EXPECT().
252		SaveToken(mock.Anything, mock.Anything, mock.Anything).
253		Run(func(_ context.Context, t *domain.Token, _ string) {
254			capturedToken = t
255		}).
256		Return(nil)
257
258	token, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, time.Hour)
259	assert.NoError(t, err)
260
261	// Validation should call GetToken and IsRevoked
262	tokenStore.EXPECT().
263		GetToken(mock.Anything, "testuser").
264		Return(token, capturedToken, nil)
265	tokenStore.EXPECT().
266		IsRevoked(mock.Anything, capturedToken.ID).
267		Return(false, nil)
268
269	claims, err := svc.ValidateToken(ctx, token)
270	assert.NoError(t, err)
271	assert.Equal(t, "testuser", claims.Subject)
272}
273
274func TestService_ValidateToken_NeverExpiringRequiresStore(t *testing.T) {
275	// Tokens with ExpiresAt = 0 (never expires) must exist in store
276	tokenStore := mocks.NewMockTokenStore(t)
277
278	svc := NewService(Config{
279		Enabled:     true,
280		AuthType:    domain.AuthTypeToken,
281		TokenSecret: []byte("test-secret-key-for-jwt-signing"),
282	}, tokenStore, zerowrap.Default())
283
284	ctx := testContext()
285
286	// Generate a never-expiring token (stored)
287	var capturedToken *domain.Token
288	tokenStore.EXPECT().
289		SaveToken(mock.Anything, mock.Anything, mock.Anything).
290		Run(func(_ context.Context, t *domain.Token, _ string) {
291			capturedToken = t
292		}).
293		Return(nil)
294
295	token, err := svc.GenerateToken(ctx, "ci-bot", []string{"push", "pull"}, 0)
296	assert.NoError(t, err)
297
298	// Validation should call GetToken and IsRevoked (not bypass store)
299	tokenStore.EXPECT().
300		GetToken(mock.Anything, "ci-bot").
301		Return(token, capturedToken, nil)
302	tokenStore.EXPECT().
303		IsRevoked(mock.Anything, capturedToken.ID).
304		Return(false, nil)
305
306	claims, err := svc.ValidateToken(ctx, token)
307	assert.NoError(t, err)
308	assert.Equal(t, "ci-bot", claims.Subject)
309	assert.Equal(t, int64(0), claims.ExpiresAt) // Never expires
310}
311
312func TestService_ValidateToken_Success(t *testing.T) {
313	tokenStore := mocks.NewMockTokenStore(t)
314
315	svc := NewService(Config{
316		Enabled:     true,
317		AuthType:    domain.AuthTypeToken,
318		TokenSecret: []byte("test-secret-key-for-jwt-signing"),
319	}, tokenStore, zerowrap.Default())
320
321	// Capture the token ID when saving
322	var savedTokenID string
323	tokenStore.EXPECT().
324		SaveToken(
325			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
326			mock.MatchedBy(func(token *domain.Token) bool {
327				savedTokenID = token.ID
328				return token.Subject == "testsubject"
329			}),
330			mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
331		).
332		Return(nil)
333
334	ctx := testContext()
335	tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
336	require.NoError(t, err)
337
338	// Expect GetToken to be called to verify token exists
339	tokenStore.EXPECT().
340		GetToken(
341			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
342			"testsubject",
343		).
344		Return(tokenStr, &domain.Token{
345			ID:      savedTokenID,
346			Subject: "testsubject",
347			Scopes:  []string{"push", "pull"},
348		}, nil)
349
350	// Expect IsRevoked to be called with the same token ID
351	tokenStore.EXPECT().
352		IsRevoked(
353			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
354			mock.MatchedBy(func(tokenID string) bool {
355				return tokenID == savedTokenID
356			}),
357		).
358		Return(false, nil)
359
360	claims, err := svc.ValidateToken(ctx, tokenStr)
361
362	assert.NoError(t, err)
363	assert.NotNil(t, claims)
364	assert.Equal(t, "testsubject", claims.Subject)
365	assert.Equal(t, savedTokenID, claims.ID)
366	assert.Equal(t, TokenIssuer, claims.Issuer)
367	assert.Contains(t, claims.Scopes, "push")
368	assert.Contains(t, claims.Scopes, "pull")
369}
370
371func TestService_ValidateToken_Revoked(t *testing.T) {
372	tokenStore := mocks.NewMockTokenStore(t)
373
374	svc := NewService(Config{
375		Enabled:     true,
376		AuthType:    domain.AuthTypeToken,
377		TokenSecret: []byte("test-secret-key-for-jwt-signing"),
378	}, tokenStore, zerowrap.Default())
379
380	var savedTokenID string
381	tokenStore.EXPECT().
382		SaveToken(
383			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
384			mock.MatchedBy(func(token *domain.Token) bool {
385				savedTokenID = token.ID
386				return token.Subject == "testsubject"
387			}),
388			mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
389		).
390		Return(nil)
391
392	ctx := testContext()
393	tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
394	require.NoError(t, err)
395
396	// Expect GetToken to be called to verify token exists
397	tokenStore.EXPECT().
398		GetToken(
399			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
400			"testsubject",
401		).
402		Return(tokenStr, &domain.Token{
403			ID:      savedTokenID,
404			Subject: "testsubject",
405			Scopes:  []string{"push", "pull"},
406		}, nil)
407
408	// Token is revoked
409	tokenStore.EXPECT().
410		IsRevoked(
411			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
412			mock.MatchedBy(func(tokenID string) bool { return tokenID == savedTokenID }),
413		).
414		Return(true, nil)
415
416	claims, err := svc.ValidateToken(ctx, tokenStr)
417
418	assert.Error(t, err)
419	assert.Nil(t, claims)
420	assert.ErrorIs(t, err, domain.ErrRevokedToken)
421}
422
423func TestService_ValidateToken_InvalidSignature(t *testing.T) {
424	tokenStore := mocks.NewMockTokenStore(t)
425
426	// Service with different secret for validation
427	svc := NewService(Config{
428		Enabled:     true,
429		AuthType:    domain.AuthTypeToken,
430		TokenSecret: []byte("different-secret-key"),
431	}, tokenStore, zerowrap.Default())
432
433	// Create a token with a different secret
434	otherSvc := NewService(Config{
435		Enabled:     true,
436		AuthType:    domain.AuthTypeToken,
437		TokenSecret: []byte("original-secret-key"),
438	}, tokenStore, zerowrap.Default())
439
440	tokenStore.EXPECT().
441		SaveToken(
442			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
443			mock.MatchedBy(func(token *domain.Token) bool { return token.Subject == "testsubject" }),
444			mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
445		).
446		Return(nil)
447
448	ctx := testContext()
449	tokenStr, err := otherSvc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
450	require.NoError(t, err)
451
452	// Try to validate with wrong secret - should fail before checking revocation
453	claims, err := svc.ValidateToken(ctx, tokenStr)
454
455	assert.Error(t, err)
456	assert.Nil(t, claims)
457	assert.ErrorIs(t, err, domain.ErrInvalidToken)
458}
459
460func TestService_ValidateToken_Expired(t *testing.T) {
461	tokenStore := mocks.NewMockTokenStore(t)
462
463	svc := NewService(Config{
464		Enabled:     true,
465		AuthType:    domain.AuthTypeToken,
466		TokenSecret: []byte("test-secret-key"),
467	}, tokenStore, zerowrap.Default())
468
469	// Generate a token that expires immediately
470	tokenStore.EXPECT().
471		SaveToken(
472			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
473			mock.MatchedBy(func(token *domain.Token) bool { return token.Subject == "testsubject" }),
474			mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
475		).
476		Return(nil)
477
478	ctx := testContext()
479	// Generate with very short expiry
480	tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push"}, time.Millisecond)
481	require.NoError(t, err)
482
483	// Wait for token to expire
484	time.Sleep(10 * time.Millisecond)
485
486	// JWT library handles expiry during parsing - returns invalid token error
487	// No IsRevoked call is made because parsing fails first
488	claims, err := svc.ValidateToken(ctx, tokenStr)
489
490	assert.Error(t, err)
491	assert.Nil(t, claims)
492	// JWT library returns invalid token for expired tokens during parsing
493	assert.ErrorIs(t, err, domain.ErrInvalidToken)
494}
495
496func TestService_ValidateToken_MalformedToken(t *testing.T) {
497	tokenStore := mocks.NewMockTokenStore(t)
498
499	svc := NewService(Config{
500		Enabled:     true,
501		AuthType:    domain.AuthTypeToken,
502		TokenSecret: []byte("test-secret-key"),
503	}, tokenStore, zerowrap.Default())
504
505	ctx := testContext()
506
507	// Test with various malformed tokens
508	malformedTokens := []string{
509		"",
510		"not-a-jwt",
511		"only.two.parts.here.extra",
512		"invalid.base64.token",
513	}
514
515	for _, token := range malformedTokens {
516		claims, err := svc.ValidateToken(ctx, token)
517		assert.Error(t, err, "expected error for token: %s", token)
518		assert.Nil(t, claims)
519		assert.ErrorIs(t, err, domain.ErrInvalidToken)
520	}
521}
522
523func TestService_RevokeToken(t *testing.T) {
524	tokenStore := mocks.NewMockTokenStore(t)
525
526	tokenID := "550e8400-e29b-41d4-a716-446655440000"
527
528	tokenStore.EXPECT().
529		Revoke(
530			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
531			tokenID,
532		).
533		Return(nil)
534
535	svc := NewService(Config{
536		Enabled:  true,
537		AuthType: domain.AuthTypeToken,
538	}, tokenStore, zerowrap.Default())
539
540	ctx := testContext()
541	err := svc.RevokeToken(ctx, tokenID)
542
543	assert.NoError(t, err)
544}
545
546func TestService_RevokeAllTokens(t *testing.T) {
547	t.Run("revokes all active tokens", func(t *testing.T) {
548		tokenStore := mocks.NewMockTokenStore(t)
549
550		tokens := []domain.Token{
551			{ID: "token-1", Subject: "user1", Revoked: false},
552			{ID: "token-2", Subject: "user2", Revoked: false},
553			{ID: "token-3", Subject: "user3", Revoked: true}, // already revoked
554		}
555
556		tokenStore.EXPECT().
557			ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
558			Return(tokens, nil)
559
560		// Should only revoke the two non-revoked tokens
561		tokenStore.EXPECT().
562			Revoke(mock.Anything, "token-1").
563			Return(nil)
564		tokenStore.EXPECT().
565			Revoke(mock.Anything, "token-2").
566			Return(nil)
567
568		svc := NewService(Config{
569			Enabled:  true,
570			AuthType: domain.AuthTypeToken,
571		}, tokenStore, zerowrap.Default())
572
573		ctx := testContext()
574		count, err := svc.RevokeAllTokens(ctx)
575
576		require.NoError(t, err)
577		assert.Equal(t, 2, count)
578	})
579
580	t.Run("returns zero when no active tokens", func(t *testing.T) {
581		tokenStore := mocks.NewMockTokenStore(t)
582
583		tokens := []domain.Token{
584			{ID: "token-1", Subject: "user1", Revoked: true},
585		}
586
587		tokenStore.EXPECT().
588			ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
589			Return(tokens, nil)
590
591		svc := NewService(Config{
592			Enabled:  true,
593			AuthType: domain.AuthTypeToken,
594		}, tokenStore, zerowrap.Default())
595
596		ctx := testContext()
597		count, err := svc.RevokeAllTokens(ctx)
598
599		require.NoError(t, err)
600		assert.Equal(t, 0, count)
601	})
602
603	t.Run("returns zero when no tokens exist", func(t *testing.T) {
604		tokenStore := mocks.NewMockTokenStore(t)
605
606		tokenStore.EXPECT().
607			ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
608			Return([]domain.Token{}, nil)
609
610		svc := NewService(Config{
611			Enabled:  true,
612			AuthType: domain.AuthTypeToken,
613		}, tokenStore, zerowrap.Default())
614
615		ctx := testContext()
616		count, err := svc.RevokeAllTokens(ctx)
617
618		require.NoError(t, err)
619		assert.Equal(t, 0, count)
620	})
621
622	t.Run("returns error when ListTokens fails", func(t *testing.T) {
623		tokenStore := mocks.NewMockTokenStore(t)
624
625		tokenStore.EXPECT().
626			ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
627			Return(nil, errors.New("storage unavailable"))
628
629		svc := NewService(Config{
630			Enabled:  true,
631			AuthType: domain.AuthTypeToken,
632		}, tokenStore, zerowrap.Default())
633
634		ctx := testContext()
635		count, err := svc.RevokeAllTokens(ctx)
636
637		require.Error(t, err)
638		assert.Contains(t, err.Error(), "failed to list tokens")
639		assert.Equal(t, 0, count)
640	})
641
642	t.Run("continues on partial revoke failure", func(t *testing.T) {
643		tokenStore := mocks.NewMockTokenStore(t)
644
645		tokens := []domain.Token{
646			{ID: "token-1", Subject: "user1", Revoked: false},
647			{ID: "token-2", Subject: "user2", Revoked: false},
648			{ID: "token-3", Subject: "user3", Revoked: false},
649		}
650
651		tokenStore.EXPECT().
652			ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
653			Return(tokens, nil)
654
655		// First token succeeds
656		tokenStore.EXPECT().
657			Revoke(mock.Anything, "token-1").
658			Return(nil)
659		// Second token fails
660		tokenStore.EXPECT().
661			Revoke(mock.Anything, "token-2").
662			Return(errors.New("revoke failed"))
663		// Third token succeeds
664		tokenStore.EXPECT().
665			Revoke(mock.Anything, "token-3").
666			Return(nil)
667
668		svc := NewService(Config{
669			Enabled:  true,
670			AuthType: domain.AuthTypeToken,
671		}, tokenStore, zerowrap.Default())
672
673		ctx := testContext()
674		count, err := svc.RevokeAllTokens(ctx)
675
676		// Should not return error, but count only successful revokes
677		require.NoError(t, err)
678		assert.Equal(t, 2, count)
679	})
680}
681
682func TestService_ListTokens(t *testing.T) {
683	tokenStore := mocks.NewMockTokenStore(t)
684
685	expectedTokens := []domain.Token{
686		{
687			ID:        "token-1",
688			Subject:   "user1",
689			Scopes:    []string{"push", "pull"},
690			IssuedAt:  time.Now().Add(-24 * time.Hour),
691			ExpiresAt: time.Now().Add(24 * time.Hour),
692			Revoked:   false,
693		},
694		{
695			ID:        "token-2",
696			Subject:   "ci-bot",
697			Scopes:    []string{"push", "pull"},
698			IssuedAt:  time.Now().Add(-48 * time.Hour),
699			ExpiresAt: time.Time{}, // Never expires
700			Revoked:   false,
701		},
702	}
703
704	tokenStore.EXPECT().
705		ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
706		Return(expectedTokens, nil)
707
708	svc := NewService(Config{
709		Enabled:  true,
710		AuthType: domain.AuthTypeToken,
711	}, tokenStore, zerowrap.Default())
712
713	ctx := testContext()
714	tokens, err := svc.ListTokens(ctx)
715
716	assert.NoError(t, err)
717	assert.Len(t, tokens, 2)
718	assert.Equal(t, "user1", tokens[0].Subject)
719	assert.Equal(t, "ci-bot", tokens[1].Subject)
720	assert.True(t, tokens[1].ExpiresAt.IsZero(), "CI bot token should never expire")
721}
722
723func TestService_GeneratePasswordHash(t *testing.T) {
724	svc := NewService(Config{}, nil, zerowrap.Default())
725
726	password := "securePassword123!"
727	hash, err := svc.GeneratePasswordHash(password)
728
729	assert.NoError(t, err)
730	assert.NotEmpty(t, hash)
731	assert.NotEqual(t, password, hash, "hash should not equal plain password")
732
733	// Verify the hash works with bcrypt
734	err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
735	assert.NoError(t, err)
736
737	// Verify wrong password fails
738	err = bcrypt.CompareHashAndPassword([]byte(hash), []byte("wrongpassword"))
739	assert.Error(t, err)
740}
741
742func TestService_GeneratePasswordHash_DifferentHashesForSamePassword(t *testing.T) {
743	svc := NewService(Config{}, nil, zerowrap.Default())
744
745	password := "testpassword"
746	hash1, err := svc.GeneratePasswordHash(password)
747	require.NoError(t, err)
748
749	hash2, err := svc.GeneratePasswordHash(password)
750	require.NoError(t, err)
751
752	// Bcrypt should generate different hashes each time due to salt
753	assert.NotEqual(t, hash1, hash2, "bcrypt should generate unique hashes")
754
755	// But both should validate correctly
756	assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password)))
757	assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password)))
758}
759
760func TestService_GenerateToken_HasNbfClaim(t *testing.T) {
761	// Test that generated tokens include the nbf (not-before) claim
762	// This is a security requirement per SEC-MED-006
763	tokenStore := mocks.NewMockTokenStore(t)
764
765	tokenStore.EXPECT().
766		SaveToken(mock.Anything, mock.Anything, mock.Anything).
767		Return(nil)
768
769	svc := NewService(Config{
770		Enabled:     true,
771		AuthType:    domain.AuthTypeToken,
772		TokenSecret: []byte("test-secret-key-for-jwt-signing"),
773	}, tokenStore, zerowrap.Default())
774
775	ctx := testContext()
776	tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push"}, time.Hour)
777	require.NoError(t, err)
778
779	// Parse the token to verify nbf claim
780	claims, err := svc.parseTokenClaims(tokenStr)
781	require.NoError(t, err)
782
783	// Verify nbf claim exists and is valid
784	nbf, ok := claims["nbf"].(float64)
785	require.True(t, ok, "nbf claim should exist and be a number")
786
787	iat, ok := claims["iat"].(float64)
788	require.True(t, ok, "iat claim should exist")
789
790	// nbf should equal iat for immediate validity
791	assert.Equal(t, iat, nbf, "nbf should equal iat")
792	assert.InDelta(t, time.Now().Unix(), int64(nbf), 5, "nbf should be current time")
793}
794
795func TestService_GenerateAccessToken_HasNbfClaim(t *testing.T) {
796	// Test that access tokens also include the nbf claim
797	svc := NewService(Config{
798		Enabled:     true,
799		AuthType:    domain.AuthTypeToken,
800		TokenSecret: []byte("test-secret-key-for-jwt-signing"),
801	}, nil, zerowrap.Default())
802
803	ctx := testContext()
804	tokenStr, err := svc.GenerateAccessToken(ctx, "testsubject", []string{"pull"}, time.Minute)
805	require.NoError(t, err)
806
807	// Parse the token to verify nbf claim
808	claims, err := svc.parseTokenClaims(tokenStr)
809	require.NoError(t, err)
810
811	// Verify nbf claim exists
812	nbf, ok := claims["nbf"].(float64)
813	require.True(t, ok, "nbf claim should exist and be a number")
814
815	iat, ok := claims["iat"].(float64)
816	require.True(t, ok, "iat claim should exist")
817
818	assert.Equal(t, iat, nbf, "nbf should equal iat for access tokens")
819}
820
821func TestExtendTokenSlidesExpiry(t *testing.T) {
822	tokenStore := mocks.NewMockTokenStore(t)
823
824	svc := NewService(Config{
825		Enabled:     true,
826		AuthType:    domain.AuthTypeToken,
827		TokenSecret: []byte("test-secret-key-for-jwt-signing"),
828	}, tokenStore, zerowrap.Default())
829
830	ctx := testContext()
831
832	// Generate a token with a short remaining life (1h) so ExtendToken produces a different exp
833	var capturedToken *domain.Token
834	tokenStore.EXPECT().
835		SaveToken(mock.Anything, mock.Anything, mock.Anything).
836		Run(func(_ context.Context, t *domain.Token, _ string) {
837			capturedToken = t
838		}).
839		Return(nil)
840
841	tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour)
842	require.NoError(t, err)
843
844	// ExtendToken flow:
845	// 1. ValidateToken → GetToken (ensureTokenExists) + IsRevoked
846	tokenStore.EXPECT().
847		GetToken(mock.Anything, "testuser").
848		Return(tokenStr, capturedToken, nil).Once()
849	tokenStore.EXPECT().
850		IsRevoked(mock.Anything, capturedToken.ID).
851		Return(false, nil).Once()
852
853	// 2. GetToken again for debounce check
854	tokenStore.EXPECT().
855		GetToken(mock.Anything, "testuser").
856		Return(tokenStr, capturedToken, nil).Once()
857
858	// 3. UpdateTokenExpiry
859	var updatedToken *domain.Token
860	tokenStore.EXPECT().
861		UpdateTokenExpiry(mock.Anything, mock.Anything, mock.Anything).
862		Run(func(_ context.Context, t *domain.Token, _ string) {
863			updatedToken = t
864		}).
865		Return(nil)
866
867	// Extend it
868	newTokenStr, err := svc.ExtendToken(ctx, tokenStr)
869	require.NoError(t, err, "ExtendToken should not fail")
870
871	// Since the original token expires in 1h and the new one expires in 24h, they must differ
872	assert.NotEqual(t, tokenStr, newTokenStr, "expected a new token string after extension")
873	assert.NotNil(t, updatedToken)
874	assert.False(t, updatedToken.LastExtendedAt.IsZero(), "LastExtendedAt should be set")
875
876	// New token must be valid — set up mock expectations for ValidateToken
877	tokenStore.EXPECT().
878		GetToken(mock.Anything, "testuser").
879		Return(newTokenStr, updatedToken, nil)
880	tokenStore.EXPECT().
881		IsRevoked(mock.Anything, capturedToken.ID).
882		Return(false, nil)
883
884	claims, err := svc.ValidateToken(ctx, newTokenStr)
885	require.NoError(t, err, "new token must be valid")
886
887	// New token should have ~24h expiry from now
888	expectedExpiry := time.Now().Add(24 * time.Hour)
889	actualExpiry := time.Unix(claims.ExpiresAt, 0)
890	assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Minute, "expiry should be ~24h from now")
891}
892
893func TestExtendTokenDebounce(t *testing.T) {
894	tokenStore := mocks.NewMockTokenStore(t)
895
896	svc := NewService(Config{
897		Enabled:     true,
898		AuthType:    domain.AuthTypeToken,
899		TokenSecret: []byte("test-secret-key-for-jwt-signing"),
900	}, tokenStore, zerowrap.Default())
901
902	ctx := testContext()
903
904	// Generate a short-lived token (1h) so the first extension produces a different JWT
905	var capturedToken *domain.Token
906	tokenStore.EXPECT().
907		SaveToken(mock.Anything, mock.Anything, mock.Anything).
908		Run(func(_ context.Context, t *domain.Token, _ string) {
909			capturedToken = t
910		}).
911		Return(nil)
912
913	tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour)
914	require.NoError(t, err)
915
916	// First extend: flow is ValidateToken(GetToken+IsRevoked) + GetToken(debounce) + UpdateTokenExpiry
917	tokenStore.EXPECT().
918		GetToken(mock.Anything, "testuser").
919		Return(tokenStr, capturedToken, nil).Once()
920	tokenStore.EXPECT().
921		IsRevoked(mock.Anything, capturedToken.ID).
922		Return(false, nil).Once()
923	tokenStore.EXPECT().
924		GetToken(mock.Anything, "testuser").
925		Return(tokenStr, capturedToken, nil).Once()
926
927	var extendedToken *domain.Token
928	var extendedJWT string
929	tokenStore.EXPECT().
930		UpdateTokenExpiry(mock.Anything, mock.Anything, mock.Anything).
931		Run(func(_ context.Context, t *domain.Token, jwt string) {
932			extendedToken = t
933			extendedJWT = jwt
934		}).
935		Return(nil).Once()
936
937	newToken1, err := svc.ExtendToken(ctx, tokenStr)
938	require.NoError(t, err)
939	assert.NotEqual(t, tokenStr, newToken1, "first extend should produce a new token")
940	assert.NotNil(t, extendedToken)
941
942	// Second extend immediately — debounce should kick in (LastExtendedAt just set to now)
943	// Flow: ValidateToken(GetToken+IsRevoked) + GetToken(debounce) → debounced, no UpdateTokenExpiry
944	tokenStore.EXPECT().
945		GetToken(mock.Anything, "testuser").
946		Return(extendedJWT, extendedToken, nil).Once()
947	tokenStore.EXPECT().
948		IsRevoked(mock.Anything, capturedToken.ID).
949		Return(false, nil).Once()
950	tokenStore.EXPECT().
951		GetToken(mock.Anything, "testuser").
952		Return(extendedJWT, extendedToken, nil).Once()
953
954	// No UpdateTokenExpiry should be called — debounced
955	newToken2, err := svc.ExtendToken(ctx, newToken1)
956	require.NoError(t, err)
957
958	assert.Equal(t, newToken1, newToken2, "expected debounce to return same token within 1h window")
959}
960
961func TestExtendTokenSkipsEphemeral(t *testing.T) {
962	svc := NewService(Config{
963		Enabled:     true,
964		AuthType:    domain.AuthTypeToken,
965		TokenSecret: []byte("test-secret-key-for-jwt-signing"),
966	}, nil, zerowrap.Default())
967
968	ctx := testContext()
969
970	// Generate an ephemeral access token (≤5min)
971	tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"pull"}, 5*time.Minute)
972	require.NoError(t, err)
973
974	// ExtendToken should skip ephemeral tokens and return the same string
975	result, err := svc.ExtendToken(ctx, tokenStr)
976	require.NoError(t, err)
977	assert.Equal(t, tokenStr, result, "ephemeral tokens should not be extended")
978}
979
980func newTestAuthService(t *testing.T) (*Service, *mocks.MockTokenStore) {
981	t.Helper()
982	tokenStore := mocks.NewMockTokenStore(t)
983	svc := NewService(Config{
984		Enabled:     true,
985		AuthType:    domain.AuthTypeToken,
986		TokenSecret: []byte("test-secret-key-for-jwt-signing"),
987	}, tokenStore, zerowrap.Default())
988	return svc, tokenStore
989}
990
991func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) {
992	// A token with iat in the future should NOT be treated as ephemeral
993	// (otherwise it bypasses revocation checks)
994	svc, _ := newTestAuthService(t)
995
996	// Craft a token with iat = now+10min, exp = now+15min (age appears as -10min → negative → < 5min).
997	// nbf is set to current time (not future) so the JWT library's nbf check does not reject it —
998	// we need to isolate the iat bypass specifically.
999	now := time.Now().UTC()
1000	claims := jwt.MapClaims{
1001		"jti":    "test-jti",
1002		"sub":    "testuser",
1003		"iss":    TokenIssuer,
1004		"iat":    now.Add(10 * time.Minute).Unix(), // future iat — the attack vector
1005		"nbf":    now.Add(-1 * time.Second).Unix(), // current nbf so nbf check passes
1006		"exp":    now.Add(15 * time.Minute).Unix(), // exp - iat = 5min → looks ephemeral
1007		"scopes": []string{"admin:*:*"},
1008	}
1009	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
1010	tokenStr, err := token.SignedString(svc.config.TokenSecret)
1011	if err != nil {
1012		t.Fatal(err)
1013	}
1014
1015	// Parsing should either reject the token outright (future iat) or not classify it as ephemeral
1016	rawClaims, err := svc.parseTokenClaims(tokenStr)
1017	if err != nil {
1018		// Acceptable: future iat rejected at parse time
1019		t.Logf("token with future iat rejected at parse: %v", err)
1020		return
1021	}
1022	tokenClaims := buildTokenClaims(rawClaims)
1023	if svc.isEphemeralAccessToken(tokenClaims) {
1024		t.Error("token with future iat must NOT be classified as ephemeral — it bypasses revocation")
1025	}
1026}
1027
1028func TestExtendTokenSkipsServiceToken(t *testing.T) {
1029	tokenStore := mocks.NewMockTokenStore(t)
1030
1031	svc := NewService(Config{
1032		Enabled:     true,
1033		AuthType:    domain.AuthTypeToken,
1034		TokenSecret: []byte("test-secret-key-for-jwt-signing"),
1035	}, tokenStore, zerowrap.Default())
1036
1037	ctx := testContext()
1038
1039	// Generate a service token — service tokens skip extension before store checks
1040	tokenStore.EXPECT().
1041		SaveToken(mock.Anything, mock.Anything, mock.Anything).
1042		Return(nil)
1043
1044	const svcSubject = "gordon-service"
1045	tokenStr, err := svc.GenerateToken(ctx, svcSubject, []string{"pull"}, 24*time.Hour)
1046	require.NoError(t, err)
1047
1048	// ExtendToken should skip service tokens WITHOUT touching the store
1049	result, err := svc.ExtendToken(ctx, tokenStr)
1050	require.NoError(t, err)
1051	assert.Equal(t, tokenStr, result, "service tokens should not be extended")
1052}

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