v2.4.1 7c3b292

service_test.go RawHistoryFolder

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}

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