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}