package services import ( "context" "os" "testing" "time" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" "go.uber.org/zap" ) func setupTestAccountLockoutService(t *testing.T) (*AccountLockoutService, *redis.Client, func()) { redisURL := os.Getenv("REDIS_TEST_URL") if redisURL == "" { // Use testcontainers if Redis URL not provided ctx := context.Background() redisC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: testcontainers.ContainerRequest{ Image: "redis:7-alpine", ExposedPorts: []string{"6379/tcp"}, WaitingFor: wait.ForLog("Ready to accept connections"), }, Started: true, }) if err != nil { t.Skipf("Skipping test: failed to start Redis container: %v", err) return nil, nil, func() {} } endpoint, err := redisC.Endpoint(ctx, "") if err != nil { t.Skipf("Skipping test: failed to get Redis endpoint: %v", err) return nil, nil, func() {} } client := redis.NewClient(&redis.Options{Addr: endpoint}) logger := zap.NewNop() service := NewAccountLockoutService(client, logger) cleanup := func() { client.FlushDB(ctx) client.Close() redisC.Terminate(ctx) } return service, client, cleanup } // Use provided Redis URL opts, err := redis.ParseURL(redisURL) if err != nil { t.Skipf("Skipping test: failed to parse Redis URL: %v", err) return nil, nil, func() {} } client := redis.NewClient(opts) ctx := context.Background() _, err = client.Ping(ctx).Result() if err != nil { t.Skipf("Skipping test: Redis not available: %v", err) return nil, nil, func() {} } client.FlushDB(ctx) logger := zap.NewNop() service := NewAccountLockoutService(client, logger) cleanup := func() { client.FlushDB(ctx) client.Close() } return service, client, cleanup } func TestAccountLockoutService_NewAccountLockoutService(t *testing.T) { service, _, cleanup := setupTestAccountLockoutService(t) if service == nil { return } defer cleanup() assert.NotNil(t, service) assert.NotNil(t, service.redisClient) assert.NotNil(t, service.logger) assert.Equal(t, 5, service.maxAttempts) assert.Equal(t, 30*time.Minute, service.lockoutDuration) assert.Equal(t, 15*time.Minute, service.windowDuration) } func TestAccountLockoutService_NewAccountLockoutServiceWithConfig(t *testing.T) { service, _, cleanup := setupTestAccountLockoutService(t) if service == nil { return } defer cleanup() config := &AccountLockoutConfig{ MaxAttempts: 10, LockoutDuration: 60 * time.Minute, WindowDuration: 30 * time.Minute, } newService := NewAccountLockoutServiceWithConfig(service.redisClient, service.logger, config) assert.NotNil(t, newService) assert.Equal(t, 10, newService.maxAttempts) assert.Equal(t, 60*time.Minute, newService.lockoutDuration) assert.Equal(t, 30*time.Minute, newService.windowDuration) } func TestAccountLockoutService_NewAccountLockoutServiceWithConfig_NilConfig(t *testing.T) { service, _, cleanup := setupTestAccountLockoutService(t) if service == nil { return } defer cleanup() newService := NewAccountLockoutServiceWithConfig(service.redisClient, service.logger, nil) assert.NotNil(t, newService) assert.Equal(t, 5, newService.maxAttempts) // Default values } func TestAccountLockoutService_DefaultAccountLockoutConfig(t *testing.T) { config := DefaultAccountLockoutConfig() assert.NotNil(t, config) assert.Equal(t, 5, config.MaxAttempts) assert.Equal(t, 30*time.Minute, config.LockoutDuration) assert.Equal(t, 15*time.Minute, config.WindowDuration) } func TestAccountLockoutService_RecordFailedAttempt_Success(t *testing.T) { service, _, cleanup := setupTestAccountLockoutService(t) if service == nil { return } defer cleanup() ctx := context.Background() email := "test@example.com" err := service.RecordFailedAttempt(ctx, email) assert.NoError(t, err) // Verify attempt was recorded count, err := service.GetFailedAttemptsCount(ctx, email) assert.NoError(t, err) assert.Equal(t, 1, count) } func TestAccountLockoutService_RecordFailedAttempt_MultipleAttempts(t *testing.T) { service, _, cleanup := setupTestAccountLockoutService(t) if service == nil { return } defer cleanup() ctx := context.Background() email := "test@example.com" // Record multiple failed attempts for i := 0; i < 3; i++ { err := service.RecordFailedAttempt(ctx, email) assert.NoError(t, err) } // Verify count count, err := service.GetFailedAttemptsCount(ctx, email) assert.NoError(t, err) assert.Equal(t, 3, count) } func TestAccountLockoutService_RecordFailedAttempt_LocksAfterMaxAttempts(t *testing.T) { service, _, cleanup := setupTestAccountLockoutService(t) if service == nil { return } defer cleanup() // Create service with lower max attempts for testing config := &AccountLockoutConfig{ MaxAttempts: 3, LockoutDuration: 5 * time.Minute, WindowDuration: 15 * time.Minute, } service = NewAccountLockoutServiceWithConfig(service.redisClient, service.logger, config) ctx := context.Background() email := "test@example.com" // Record attempts up to max for i := 0; i < 3; i++ { err := service.RecordFailedAttempt(ctx, email) assert.NoError(t, err) } // Verify account is locked locked, lockedUntil, err := service.IsAccountLocked(ctx, email) assert.NoError(t, err) assert.True(t, locked) assert.NotNil(t, lockedUntil) assert.True(t, lockedUntil.After(time.Now())) } func TestAccountLockoutService_RecordSuccessfulLogin_ResetsAttempts(t *testing.T) { service, _, cleanup := setupTestAccountLockoutService(t) if service == nil { return } defer cleanup() ctx := context.Background() email := "test@example.com" // Record failed attempts for i := 0; i < 3; i++ { err := service.RecordFailedAttempt(ctx, email) assert.NoError(t, err) } // Record successful login err := service.RecordSuccessfulLogin(ctx, email) assert.NoError(t, err) // Verify attempts were reset count, err := service.GetFailedAttemptsCount(ctx, email) assert.NoError(t, err) assert.Equal(t, 0, count) } func TestAccountLockoutService_RecordSuccessfulLogin_UnlocksAccount(t *testing.T) { service, _, cleanup := setupTestAccountLockoutService(t) if service == nil { return } defer cleanup() // Create service with lower max attempts config := &AccountLockoutConfig{ MaxAttempts: 2, LockoutDuration: 5 * time.Minute, WindowDuration: 15 * time.Minute, } service = NewAccountLockoutServiceWithConfig(service.redisClient, service.logger, config) ctx := context.Background() email := "test@example.com" // Lock account for i := 0; i < 2; i++ { err := service.RecordFailedAttempt(ctx, email) assert.NoError(t, err) } // Verify locked locked, _, err := service.IsAccountLocked(ctx, email) assert.NoError(t, err) assert.True(t, locked) // Record successful login err = service.RecordSuccessfulLogin(ctx, email) assert.NoError(t, err) // Verify unlocked locked, _, err = service.IsAccountLocked(ctx, email) assert.NoError(t, err) assert.False(t, locked) } func TestAccountLockoutService_IsAccountLocked_NotLocked(t *testing.T) { service, _, cleanup := setupTestAccountLockoutService(t) if service == nil { return } defer cleanup() ctx := context.Background() email := "test@example.com" locked, lockedUntil, err := service.IsAccountLocked(ctx, email) assert.NoError(t, err) assert.False(t, locked) assert.Nil(t, lockedUntil) } func TestAccountLockoutService_IsAccountLocked_Locked(t *testing.T) { service, _, cleanup := setupTestAccountLockoutService(t) if service == nil { return } defer cleanup() ctx := context.Background() email := "test@example.com" // Lock account err := service.LockAccount(ctx, email) assert.NoError(t, err) // Verify locked locked, lockedUntil, err := service.IsAccountLocked(ctx, email) assert.NoError(t, err) assert.True(t, locked) assert.NotNil(t, lockedUntil) assert.True(t, lockedUntil.After(time.Now())) } func TestAccountLockoutService_LockAccount_Success(t *testing.T) { service, _, cleanup := setupTestAccountLockoutService(t) if service == nil { return } defer cleanup() ctx := context.Background() email := "test@example.com" err := service.LockAccount(ctx, email) assert.NoError(t, err) // Verify locked locked, _, err := service.IsAccountLocked(ctx, email) assert.NoError(t, err) assert.True(t, locked) } func TestAccountLockoutService_UnlockAccount_Success(t *testing.T) { service, _, cleanup := setupTestAccountLockoutService(t) if service == nil { return } defer cleanup() ctx := context.Background() email := "test@example.com" // Lock account first err := service.LockAccount(ctx, email) assert.NoError(t, err) // Unlock account err = service.UnlockAccount(ctx, email) assert.NoError(t, err) // Verify unlocked locked, _, err := service.IsAccountLocked(ctx, email) assert.NoError(t, err) assert.False(t, locked) } func TestAccountLockoutService_UnlockAccount_ResetsAttempts(t *testing.T) { service, _, cleanup := setupTestAccountLockoutService(t) if service == nil { return } defer cleanup() ctx := context.Background() email := "test@example.com" // Record failed attempts for i := 0; i < 3; i++ { err := service.RecordFailedAttempt(ctx, email) assert.NoError(t, err) } // Unlock account err := service.UnlockAccount(ctx, email) assert.NoError(t, err) // Verify attempts were reset count, err := service.GetFailedAttemptsCount(ctx, email) assert.NoError(t, err) assert.Equal(t, 0, count) } func TestAccountLockoutService_GetFailedAttemptsCount_Zero(t *testing.T) { service, _, cleanup := setupTestAccountLockoutService(t) if service == nil { return } defer cleanup() ctx := context.Background() email := "test@example.com" count, err := service.GetFailedAttemptsCount(ctx, email) assert.NoError(t, err) assert.Equal(t, 0, count) } func TestAccountLockoutService_GetFailedAttemptsCount_WithAttempts(t *testing.T) { service, _, cleanup := setupTestAccountLockoutService(t) if service == nil { return } defer cleanup() ctx := context.Background() email := "test@example.com" // Record failed attempts for i := 0; i < 5; i++ { err := service.RecordFailedAttempt(ctx, email) assert.NoError(t, err) } count, err := service.GetFailedAttemptsCount(ctx, email) assert.NoError(t, err) assert.Equal(t, 5, count) } func TestAccountLockoutService_getAttemptsKey(t *testing.T) { service, _, cleanup := setupTestAccountLockoutService(t) if service == nil { return } defer cleanup() email := "test@example.com" key := service.getAttemptsKey(email) assert.Equal(t, "account_lockout:attempts:test@example.com", key) } func TestAccountLockoutService_getLockoutKey(t *testing.T) { service, _, cleanup := setupTestAccountLockoutService(t) if service == nil { return } defer cleanup() email := "test@example.com" key := service.getLockoutKey(email) assert.Equal(t, "account_lockout:locked:test@example.com", key) } func TestAccountLockoutService_RecordFailedAttempt_NoRedis(t *testing.T) { logger := zap.NewNop() service := NewAccountLockoutService(nil, logger) ctx := context.Background() email := "test@example.com" // Should not error even without Redis err := service.RecordFailedAttempt(ctx, email) assert.NoError(t, err) } func TestAccountLockoutService_IsAccountLocked_NoRedis(t *testing.T) { logger := zap.NewNop() service := NewAccountLockoutService(nil, logger) ctx := context.Background() email := "test@example.com" locked, lockedUntil, err := service.IsAccountLocked(ctx, email) assert.NoError(t, err) assert.False(t, locked) assert.Nil(t, lockedUntil) } func TestAccountLockoutService_AccountLockoutConfig_Fields(t *testing.T) { config := &AccountLockoutConfig{ MaxAttempts: 10, LockoutDuration: 60 * time.Minute, WindowDuration: 30 * time.Minute, } assert.Equal(t, 10, config.MaxAttempts) assert.Equal(t, 60*time.Minute, config.LockoutDuration) assert.Equal(t, 30*time.Minute, config.WindowDuration) }