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 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)) }