package user import ( "testing" "time" "github.com/stretchr/testify/require" ) func newThrottle(now *time.Time) *LoginThrottle { return NewLoginThrottle( LoginThrottleConfig{Enabled: true, MaxFailures: 3, Window: 10 * time.Minute}, func() time.Time { return *now }, ) } func TestLoginThrottle_LockoutAfterN(t *testing.T) { now := time.Now() th := newThrottle(&now) ip, email := "1.2.3.4", "user@example.com" for i := 0; i < 3; i++ { allowed, _ := th.Allow(ip, email) require.True(t, allowed, "attempt %d should be allowed", i+1) th.RecordFailure(ip, email) } // 4th attempt blocked. allowed, retry := th.Allow(ip, email) require.False(t, allowed) require.Greater(t, retry, time.Duration(0)) } func TestLoginThrottle_WindowExpiryResets(t *testing.T) { now := time.Now() th := newThrottle(&now) ip, email := "1.2.3.4", "user@example.com" for i := 0; i < 3; i++ { th.RecordFailure(ip, email) } blocked, _ := th.Allow(ip, email) require.False(t, blocked) // Advance past the window: counter is stale -> allowed again. now = now.Add(11 * time.Minute) allowed, _ := th.Allow(ip, email) require.True(t, allowed) } func TestLoginThrottle_SuccessReset(t *testing.T) { now := time.Now() th := newThrottle(&now) ip, email := "1.2.3.4", "user@example.com" th.RecordFailure(ip, email) th.RecordFailure(ip, email) th.Reset(ip, email) // simulate successful login // Counter cleared: can fail 3 more times before lockout. for i := 0; i < 3; i++ { allowed, _ := th.Allow(ip, email) require.True(t, allowed) th.RecordFailure(ip, email) } blocked, _ := th.Allow(ip, email) require.False(t, blocked) } func TestLoginThrottle_PerIPEmailIsolation(t *testing.T) { now := time.Now() th := newThrottle(&now) victim := "victim@example.com" // Attacker hammers victim's email from one IP and trips the lock for that IP. for i := 0; i < 3; i++ { th.RecordFailure("9.9.9.9", victim) } blocked, _ := th.Allow("9.9.9.9", victim) require.False(t, blocked) // The real victim, on a DIFFERENT IP, is NOT locked out. allowed, _ := th.Allow("5.5.5.5", victim) require.True(t, allowed, "victim must not be locked out by an attacker on another IP") } func TestLoginThrottle_CaseInsensitiveEmail(t *testing.T) { now := time.Now() th := newThrottle(&now) ip := "1.2.3.4" for i := 0; i < 3; i++ { th.RecordFailure(ip, "User@Example.com") } // Same email different case shares the counter. blocked, _ := th.Allow(ip, "user@example.com") require.False(t, blocked) } func TestLoginThrottle_DisabledAlwaysAllows(t *testing.T) { th := NewLoginThrottle(LoginThrottleConfig{Enabled: false, MaxFailures: 1}, nil) for i := 0; i < 100; i++ { th.RecordFailure("1.1.1.1", "a@b.c") } allowed, _ := th.Allow("1.1.1.1", "a@b.c") require.True(t, allowed) }