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}