v2.1.0 0fb867d

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

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