v2.2.0 64d39bf

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_ValidateToken_Success(t *testing.T) {
183	tokenStore := mocks.NewMockTokenStore(t)
184
185	svc := NewService(Config{
186		Enabled:     true,
187		AuthType:    domain.AuthTypeToken,
188		TokenSecret: []byte("test-secret-key-for-jwt-signing"),
189	}, tokenStore, zerowrap.Default())
190
191	// Capture the token ID when saving
192	var savedTokenID string
193	tokenStore.EXPECT().
194		SaveToken(
195			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
196			mock.MatchedBy(func(token *domain.Token) bool {
197				savedTokenID = token.ID
198				return token.Subject == "testsubject"
199			}),
200			mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
201		).
202		Return(nil)
203
204	ctx := testContext()
205	tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
206	require.NoError(t, err)
207
208	// Expect GetToken to be called to verify token exists
209	tokenStore.EXPECT().
210		GetToken(
211			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
212			"testsubject",
213		).
214		Return(tokenStr, &domain.Token{
215			ID:      savedTokenID,
216			Subject: "testsubject",
217			Scopes:  []string{"push", "pull"},
218		}, nil)
219
220	// Expect IsRevoked to be called with the same token ID
221	tokenStore.EXPECT().
222		IsRevoked(
223			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
224			mock.MatchedBy(func(tokenID string) bool {
225				return tokenID == savedTokenID
226			}),
227		).
228		Return(false, nil)
229
230	claims, err := svc.ValidateToken(ctx, tokenStr)
231
232	assert.NoError(t, err)
233	assert.NotNil(t, claims)
234	assert.Equal(t, "testsubject", claims.Subject)
235	assert.Equal(t, savedTokenID, claims.ID)
236	assert.Equal(t, TokenIssuer, claims.Issuer)
237	assert.Contains(t, claims.Scopes, "push")
238	assert.Contains(t, claims.Scopes, "pull")
239}
240
241func TestService_ValidateToken_Revoked(t *testing.T) {
242	tokenStore := mocks.NewMockTokenStore(t)
243
244	svc := NewService(Config{
245		Enabled:     true,
246		AuthType:    domain.AuthTypeToken,
247		TokenSecret: []byte("test-secret-key-for-jwt-signing"),
248	}, tokenStore, zerowrap.Default())
249
250	var savedTokenID string
251	tokenStore.EXPECT().
252		SaveToken(
253			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
254			mock.MatchedBy(func(token *domain.Token) bool {
255				savedTokenID = token.ID
256				return token.Subject == "testsubject"
257			}),
258			mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
259		).
260		Return(nil)
261
262	ctx := testContext()
263	tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
264	require.NoError(t, err)
265
266	// Expect GetToken to be called to verify token exists
267	tokenStore.EXPECT().
268		GetToken(
269			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
270			"testsubject",
271		).
272		Return(tokenStr, &domain.Token{
273			ID:      savedTokenID,
274			Subject: "testsubject",
275			Scopes:  []string{"push", "pull"},
276		}, nil)
277
278	// Token is revoked
279	tokenStore.EXPECT().
280		IsRevoked(
281			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
282			mock.MatchedBy(func(tokenID string) bool { return tokenID == savedTokenID }),
283		).
284		Return(true, nil)
285
286	claims, err := svc.ValidateToken(ctx, tokenStr)
287
288	assert.Error(t, err)
289	assert.Nil(t, claims)
290	assert.ErrorIs(t, err, domain.ErrRevokedToken)
291}
292
293func TestService_ValidateToken_InvalidSignature(t *testing.T) {
294	tokenStore := mocks.NewMockTokenStore(t)
295
296	// Service with different secret for validation
297	svc := NewService(Config{
298		Enabled:     true,
299		AuthType:    domain.AuthTypeToken,
300		TokenSecret: []byte("different-secret-key"),
301	}, tokenStore, zerowrap.Default())
302
303	// Create a token with a different secret
304	otherSvc := NewService(Config{
305		Enabled:     true,
306		AuthType:    domain.AuthTypeToken,
307		TokenSecret: []byte("original-secret-key"),
308	}, tokenStore, zerowrap.Default())
309
310	tokenStore.EXPECT().
311		SaveToken(
312			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
313			mock.MatchedBy(func(token *domain.Token) bool { return token.Subject == "testsubject" }),
314			mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
315		).
316		Return(nil)
317
318	ctx := testContext()
319	tokenStr, err := otherSvc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
320	require.NoError(t, err)
321
322	// Try to validate with wrong secret - should fail before checking revocation
323	claims, err := svc.ValidateToken(ctx, tokenStr)
324
325	assert.Error(t, err)
326	assert.Nil(t, claims)
327	assert.ErrorIs(t, err, domain.ErrInvalidToken)
328}
329
330func TestService_ValidateToken_Expired(t *testing.T) {
331	tokenStore := mocks.NewMockTokenStore(t)
332
333	svc := NewService(Config{
334		Enabled:     true,
335		AuthType:    domain.AuthTypeToken,
336		TokenSecret: []byte("test-secret-key"),
337	}, tokenStore, zerowrap.Default())
338
339	// Generate a token that expires immediately
340	tokenStore.EXPECT().
341		SaveToken(
342			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
343			mock.MatchedBy(func(token *domain.Token) bool { return token.Subject == "testsubject" }),
344			mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
345		).
346		Return(nil)
347
348	ctx := testContext()
349	// Generate with very short expiry
350	tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push"}, time.Millisecond)
351	require.NoError(t, err)
352
353	// Wait for token to expire
354	time.Sleep(10 * time.Millisecond)
355
356	// JWT library handles expiry during parsing - returns invalid token error
357	// No IsRevoked call is made because parsing fails first
358	claims, err := svc.ValidateToken(ctx, tokenStr)
359
360	assert.Error(t, err)
361	assert.Nil(t, claims)
362	// JWT library returns invalid token for expired tokens during parsing
363	assert.ErrorIs(t, err, domain.ErrInvalidToken)
364}
365
366func TestService_ValidateToken_MalformedToken(t *testing.T) {
367	tokenStore := mocks.NewMockTokenStore(t)
368
369	svc := NewService(Config{
370		Enabled:     true,
371		AuthType:    domain.AuthTypeToken,
372		TokenSecret: []byte("test-secret-key"),
373	}, tokenStore, zerowrap.Default())
374
375	ctx := testContext()
376
377	// Test with various malformed tokens
378	malformedTokens := []string{
379		"",
380		"not-a-jwt",
381		"only.two.parts.here.extra",
382		"invalid.base64.token",
383	}
384
385	for _, token := range malformedTokens {
386		claims, err := svc.ValidateToken(ctx, token)
387		assert.Error(t, err, "expected error for token: %s", token)
388		assert.Nil(t, claims)
389		assert.ErrorIs(t, err, domain.ErrInvalidToken)
390	}
391}
392
393func TestService_RevokeToken(t *testing.T) {
394	tokenStore := mocks.NewMockTokenStore(t)
395
396	tokenID := "550e8400-e29b-41d4-a716-446655440000"
397
398	tokenStore.EXPECT().
399		Revoke(
400			mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
401			tokenID,
402		).
403		Return(nil)
404
405	svc := NewService(Config{
406		Enabled:  true,
407		AuthType: domain.AuthTypeToken,
408	}, tokenStore, zerowrap.Default())
409
410	ctx := testContext()
411	err := svc.RevokeToken(ctx, tokenID)
412
413	assert.NoError(t, err)
414}
415
416func TestService_ListTokens(t *testing.T) {
417	tokenStore := mocks.NewMockTokenStore(t)
418
419	expectedTokens := []domain.Token{
420		{
421			ID:        "token-1",
422			Subject:   "user1",
423			Scopes:    []string{"push", "pull"},
424			IssuedAt:  time.Now().Add(-24 * time.Hour),
425			ExpiresAt: time.Now().Add(24 * time.Hour),
426			Revoked:   false,
427		},
428		{
429			ID:        "token-2",
430			Subject:   "ci-bot",
431			Scopes:    []string{"push", "pull"},
432			IssuedAt:  time.Now().Add(-48 * time.Hour),
433			ExpiresAt: time.Time{}, // Never expires
434			Revoked:   false,
435		},
436	}
437
438	tokenStore.EXPECT().
439		ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
440		Return(expectedTokens, nil)
441
442	svc := NewService(Config{
443		Enabled:  true,
444		AuthType: domain.AuthTypeToken,
445	}, tokenStore, zerowrap.Default())
446
447	ctx := testContext()
448	tokens, err := svc.ListTokens(ctx)
449
450	assert.NoError(t, err)
451	assert.Len(t, tokens, 2)
452	assert.Equal(t, "user1", tokens[0].Subject)
453	assert.Equal(t, "ci-bot", tokens[1].Subject)
454	assert.True(t, tokens[1].ExpiresAt.IsZero(), "CI bot token should never expire")
455}
456
457func TestService_GeneratePasswordHash(t *testing.T) {
458	svc := NewService(Config{}, nil, zerowrap.Default())
459
460	password := "securePassword123!"
461	hash, err := svc.GeneratePasswordHash(password)
462
463	assert.NoError(t, err)
464	assert.NotEmpty(t, hash)
465	assert.NotEqual(t, password, hash, "hash should not equal plain password")
466
467	// Verify the hash works with bcrypt
468	err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
469	assert.NoError(t, err)
470
471	// Verify wrong password fails
472	err = bcrypt.CompareHashAndPassword([]byte(hash), []byte("wrongpassword"))
473	assert.Error(t, err)
474}
475
476func TestService_GeneratePasswordHash_DifferentHashesForSamePassword(t *testing.T) {
477	svc := NewService(Config{}, nil, zerowrap.Default())
478
479	password := "testpassword"
480	hash1, err := svc.GeneratePasswordHash(password)
481	require.NoError(t, err)
482
483	hash2, err := svc.GeneratePasswordHash(password)
484	require.NoError(t, err)
485
486	// Bcrypt should generate different hashes each time due to salt
487	assert.NotEqual(t, hash1, hash2, "bcrypt should generate unique hashes")
488
489	// But both should validate correctly
490	assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password)))
491	assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password)))
492}

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