package auth import ( "context" "testing" "time" "github.com/alicebob/miniredis/v2" goredis "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func newFailingRedisClient(t *testing.T) *goredis.Client { t.Helper() client := goredis.NewClient(&goredis.Options{ Addr: "127.0.0.1:1", MaxRetries: 0, DialTimeout: 10 * time.Millisecond, ReadTimeout: 10 * time.Millisecond, WriteTimeout: 10 * time.Millisecond, }) t.Cleanup(func() { _ = client.Close() }) return client } func newTestLoginGuard(t *testing.T, mutate func(*LoginGuardConfig)) (*LoginGuard, *miniredis.Miniredis) { t.Helper() mr := miniredis.RunT(t) client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) t.Cleanup(func() { _ = client.Close() }) cfg := DefaultLoginGuardConfig("test") cfg.RequestIPLimit = 100 cfg.RequestIdentifierLimit = 100 cfg.RequestPairLimit = 100 cfg.RequestIPWindow = time.Minute cfg.RequestIdentifierWindow = time.Minute cfg.RequestPairWindow = time.Minute cfg.FailureIdentifierLimit = 100 cfg.FailurePairLimit = 100 cfg.FailureWindow = 15 * time.Minute cfg.FailureLockBaseTTL = 2 * time.Minute cfg.FailureLockMaxTTL = 10 * time.Minute cfg.ConcurrentAttemptLockTTL = 10 * time.Second if mutate != nil { mutate(&cfg) } return NewLoginGuard(client, cfg), mr } func TestLoginGuardFallsBackWhenRedisUnavailable(t *testing.T) { cfg := DefaultLoginGuardConfig("test") cfg.RequestPairLimit = 100 cfg.FailurePairLimit = 2 cfg.ConcurrentAttemptLockTTL = 10 * time.Second guard := NewLoginGuard(newFailingRedisClient(t), cfg) ctx := context.Background() subject := LoginGuardSubject{Identifier: "admin@geo.local", IP: "127.0.0.1"} permit, err := guard.Acquire(ctx, subject) require.NoError(t, err) require.NotNil(t, permit) _, err = guard.Acquire(ctx, subject) var blocked *LoginGuardBlockedError require.ErrorAs(t, err, &blocked) assert.Equal(t, LoginGuardBlockedInProgress, blocked.Reason) permit.RecordFailure(ctx) permit, err = guard.Acquire(ctx, subject) require.NoError(t, err) permit.RecordFailure(ctx) _, err = guard.Acquire(ctx, subject) require.ErrorAs(t, err, &blocked) assert.Equal(t, LoginGuardBlockedLocked, blocked.Reason) } func TestLoginGuardRejectsConcurrentIdentifierAttempt(t *testing.T) { guard, _ := newTestLoginGuard(t, nil) ctx := context.Background() subject := LoginGuardSubject{Identifier: "Admin@Geo.Local", IP: "127.0.0.1"} permit, err := guard.Acquire(ctx, subject) require.NoError(t, err) require.NotNil(t, permit) _, err = guard.Acquire(ctx, subject) var blocked *LoginGuardBlockedError require.ErrorAs(t, err, &blocked) assert.Equal(t, LoginGuardBlockedInProgress, blocked.Reason) assert.Greater(t, blocked.RetryAfterSeconds(), 0) permit.Release(ctx) next, err := guard.Acquire(ctx, subject) require.NoError(t, err) require.NotNil(t, next) next.Release(ctx) } func TestLoginGuardRateLimitsRequestWindow(t *testing.T) { guard, _ := newTestLoginGuard(t, func(cfg *LoginGuardConfig) { cfg.RequestPairLimit = 1 cfg.RequestPairWindow = time.Minute }) ctx := context.Background() subject := LoginGuardSubject{Identifier: "admin@geo.local", IP: "127.0.0.1"} permit, err := guard.Acquire(ctx, subject) require.NoError(t, err) permit.RecordSuccess(ctx) _, err = guard.Acquire(ctx, subject) var blocked *LoginGuardBlockedError require.ErrorAs(t, err, &blocked) assert.Equal(t, LoginGuardBlockedRateLimited, blocked.Reason) assert.Greater(t, blocked.RetryAfterSeconds(), 0) } func TestLoginGuardLocksAfterFailedAttempts(t *testing.T) { guard, mr := newTestLoginGuard(t, func(cfg *LoginGuardConfig) { cfg.FailurePairLimit = 2 cfg.FailureLockBaseTTL = time.Minute }) ctx := context.Background() subject := LoginGuardSubject{Identifier: "admin@geo.local", IP: "127.0.0.1"} for i := 0; i < 2; i++ { permit, err := guard.Acquire(ctx, subject) require.NoError(t, err) permit.RecordFailure(ctx) } _, err := guard.Acquire(ctx, subject) var blocked *LoginGuardBlockedError require.ErrorAs(t, err, &blocked) assert.Equal(t, LoginGuardBlockedLocked, blocked.Reason) mr.FastForward(time.Minute + time.Second) permit, err := guard.Acquire(ctx, subject) require.NoError(t, err) require.NotNil(t, permit) permit.Release(ctx) } func TestLoginGuardUnlockClearsPairLocksForIdentifier(t *testing.T) { guard, _ := newTestLoginGuard(t, func(cfg *LoginGuardConfig) { cfg.FailureIdentifierLimit = 100 cfg.FailurePairLimit = 2 cfg.FailureLockBaseTTL = time.Minute }) ctx := context.Background() subject := LoginGuardSubject{Identifier: "13003088573", IP: "192.0.2.10"} for i := 0; i < 2; i++ { permit, err := guard.Acquire(ctx, subject) require.NoError(t, err) permit.RecordFailure(ctx) } _, err := guard.Acquire(ctx, subject) var blocked *LoginGuardBlockedError require.ErrorAs(t, err, &blocked) assert.Equal(t, LoginGuardBlockedLocked, blocked.Reason) result, err := guard.UnlockWithResult(ctx, subject.Identifier) require.NoError(t, err) assert.Greater(t, result.RedisDeletedKeys, int64(0)) permit, err := guard.Acquire(ctx, subject) require.NoError(t, err) require.NotNil(t, permit) permit.Release(ctx) } func TestLoginGuardUnlockMarkerClearsLegacyPairLock(t *testing.T) { guard, mr := newTestLoginGuard(t, func(cfg *LoginGuardConfig) { cfg.FailureIdentifierLimit = 100 cfg.FailurePairLimit = 2 cfg.FailureLockBaseTTL = time.Minute }) ctx := context.Background() subject := LoginGuardSubject{Identifier: "admin@geo.local", IP: "192.0.2.20"} keys := guard.keysFor(subject) require.NoError(t, mr.Set(keys.lockPair, "2")) mr.SetTTL(keys.lockPair, time.Minute) _, err := guard.Acquire(ctx, subject) var blocked *LoginGuardBlockedError require.ErrorAs(t, err, &blocked) assert.Equal(t, LoginGuardBlockedLocked, blocked.Reason) result, err := guard.UnlockWithResult(ctx, subject.Identifier) require.NoError(t, err) assert.Greater(t, result.RedisDeletedKeys, int64(0)) require.False(t, mr.Exists(keys.lockPair)) permit, err := guard.Acquire(ctx, subject) require.NoError(t, err) require.NotNil(t, permit) permit.Release(ctx) } func TestLoginGuardUnlockClearsRequestAndInflightState(t *testing.T) { guard, _ := newTestLoginGuard(t, func(cfg *LoginGuardConfig) { cfg.RequestIdentifierLimit = 1 cfg.RequestIdentifierWindow = time.Minute }) ctx := context.Background() subject := LoginGuardSubject{Identifier: "13003088573", IP: "192.0.2.30"} permit, err := guard.Acquire(ctx, subject) require.NoError(t, err) _, err = guard.Acquire(ctx, subject) var blocked *LoginGuardBlockedError require.ErrorAs(t, err, &blocked) assert.Equal(t, LoginGuardBlockedInProgress, blocked.Reason) result, err := guard.UnlockWithResult(ctx, subject.Identifier) require.NoError(t, err) assert.Greater(t, result.RedisDeletedKeys, int64(0)) permit.Release(ctx) next, err := guard.Acquire(ctx, subject) require.NoError(t, err) require.NotNil(t, next) next.Release(ctx) } func TestLoginGuardUnlockClearsWholeScopeProtectionCache(t *testing.T) { guard, mr := newTestLoginGuard(t, nil) ctx := context.Background() otherSubject := LoginGuardSubject{Identifier: "other-user", IP: "192.0.2.40"} otherKeys := guard.keysFor(otherSubject) require.NoError(t, mr.Set(otherKeys.lockIdentifier, "1")) mr.SetTTL(otherKeys.lockIdentifier, time.Minute) require.NoError(t, mr.Set(otherKeys.lockPair, "1")) mr.SetTTL(otherKeys.lockPair, time.Minute) require.NoError(t, mr.Set(otherKeys.requestIdentifier, "99")) mr.SetTTL(otherKeys.requestIdentifier, time.Minute) require.NoError(t, mr.Set(otherKeys.requestPair, "99")) mr.SetTTL(otherKeys.requestPair, time.Minute) result, err := guard.UnlockWithResult(ctx, "13003088573") require.NoError(t, err) assert.Equal(t, int64(4), result.RedisDeletedKeys) require.False(t, mr.Exists(otherKeys.lockIdentifier)) require.False(t, mr.Exists(otherKeys.lockPair)) require.False(t, mr.Exists(otherKeys.requestIdentifier)) require.False(t, mr.Exists(otherKeys.requestPair)) } func TestLoginGuardUnlockClearsAllLoginScopes(t *testing.T) { guard, mr := newTestLoginGuard(t, nil) ctx := context.Background() require.NoError(t, mr.Set("auth:login:tenant:lock:id:one", "1")) require.NoError(t, mr.Set("auth:login:ops:lock:id:two", "1")) require.NoError(t, mr.Set("auth:login:legacy:lock:id:three", "1")) require.NoError(t, mr.Set("other:key", "1")) result, err := guard.UnlockWithResult(ctx, "13003088573") require.NoError(t, err) assert.Equal(t, int64(3), result.RedisDeletedKeys) assert.False(t, mr.Exists("auth:login:tenant:lock:id:one")) assert.False(t, mr.Exists("auth:login:ops:lock:id:two")) assert.False(t, mr.Exists("auth:login:legacy:lock:id:three")) assert.True(t, mr.Exists("other:key")) assert.Contains(t, result.RedisScanPatterns, "auth:login:*") } func TestLoginGuardUnlockClearsFallbackState(t *testing.T) { cfg := DefaultLoginGuardConfig("test") cfg.RequestPairLimit = 100 cfg.FailurePairLimit = 2 cfg.ConcurrentAttemptLockTTL = 10 * time.Second guard := NewLoginGuard(nil, cfg) ctx := context.Background() subject := LoginGuardSubject{Identifier: "13003088573", IP: "192.0.2.50"} permit, err := guard.Acquire(ctx, subject) require.NoError(t, err) permit.RecordFailure(ctx) permit, err = guard.Acquire(ctx, subject) require.NoError(t, err) permit.RecordFailure(ctx) _, err = guard.Acquire(ctx, subject) var blocked *LoginGuardBlockedError require.ErrorAs(t, err, &blocked) assert.Equal(t, LoginGuardBlockedLocked, blocked.Reason) result, err := guard.UnlockWithResult(ctx, subject.Identifier) require.NoError(t, err) assert.Greater(t, result.FallbackDeletedKeys, 0) permit, err = guard.Acquire(ctx, subject) require.NoError(t, err) require.NotNil(t, permit) permit.Release(ctx) } func TestLoginGuardSuccessClearsFailureCounters(t *testing.T) { guard, mr := newTestLoginGuard(t, nil) ctx := context.Background() subject := LoginGuardSubject{Identifier: "ADMIN@Geo.Local ", IP: "127.0.0.1:43120"} keys := guard.keysFor(subject) permit, err := guard.Acquire(ctx, subject) require.NoError(t, err) permit.RecordFailure(ctx) require.True(t, mr.Exists(keys.failureIdentifier)) require.True(t, mr.Exists(keys.failurePair)) permit, err = guard.Acquire(ctx, subject) require.NoError(t, err) permit.RecordSuccess(ctx) assert.False(t, mr.Exists(keys.failureIdentifier)) assert.False(t, mr.Exists(keys.failurePair)) }