package auth import ( "testing" "time" ) // fakeClock lets a test step through a window without sleeping. type fakeClock struct{ t time.Time } func (c *fakeClock) now() time.Time { return c.t } func newTestLimiter(max int, window time.Duration) (*AttemptLimiter, *fakeClock) { c := &fakeClock{t: time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)} l := NewAttemptLimiter(max, window) l.now = c.now return l, c } func TestAttemptLimiter_BlocksAtMaxAndReportsWait(t *testing.T) { l, c := newTestLimiter(3, 15*time.Minute) for i := 0; i < 3; i++ { if blocked, _ := l.Blocked("k"); blocked { t.Fatalf("blocked after %d attempts, want allowed below max", i) } l.Record("k") } c.t = c.t.Add(5 * time.Minute) blocked, wait := l.Blocked("k") if !blocked { t.Fatal("not blocked after max attempts") } if wait != 10*time.Minute { t.Errorf("wait = %v, want the 10m left in the window", wait) } } func TestAttemptLimiter_WindowExpiryClears(t *testing.T) { l, c := newTestLimiter(1, time.Minute) l.Record("k") if blocked, _ := l.Blocked("k"); !blocked { t.Fatal("want blocked inside the window") } c.t = c.t.Add(time.Minute) if blocked, _ := l.Blocked("k"); blocked { t.Fatal("still blocked once the window has passed") } } func TestAttemptLimiter_KeysAreIndependentAndResetClears(t *testing.T) { l, _ := newTestLimiter(1, time.Minute) l.Record("a") if blocked, _ := l.Blocked("b"); blocked { t.Fatal("one key's attempts blocked another") } l.Reset("a") if blocked, _ := l.Blocked("a"); blocked { t.Fatal("Reset did not clear the key") } } func TestAttemptLimiter_SweepDropsExpiredKeys(t *testing.T) { l, c := newTestLimiter(5, time.Minute) // One short of the floor: no sweep yet, however stale these become. for i := 0; i < sweepFloor-1; i++ { l.Record("k" + time.Duration(i).String()) } c.t = c.t.Add(2 * time.Minute) l.Record("fresh") // reaches the floor and triggers a sweep if n := len(l.buckets); n != 1 { t.Errorf("buckets after sweep = %d, want only the fresh key", n) } } func TestAttemptLimiter_NilAndEmptyKeyAreNoOps(t *testing.T) { var l *AttemptLimiter l.Record("k") if blocked, _ := l.Blocked("k"); blocked { t.Error("nil limiter blocked") } real, _ := newTestLimiter(1, time.Minute) real.Record("") if blocked, _ := real.Blocked(""); blocked { t.Error("empty key was counted") } } func TestLoginGuard_AccountLimitSpansAddressesAndFoldsCase(t *testing.T) { g := NewLoginGuard() for i := 0; i < loginAccountMax; i++ { g.Fail("Alice", "10.0.0."+string(rune('0'+i%10))) } if blocked, _ := g.Blocked("alice", "192.0.2.1"); !blocked { t.Fatal("account limit should hold from a fresh address and any capitalisation") } g.Succeed("ALICE") if blocked, _ := g.Blocked("alice", "192.0.2.1"); blocked { t.Fatal("Succeed should clear the account's failures") } } func TestLoginGuard_AddressLimitSpansAccounts(t *testing.T) { g := NewLoginGuard() for i := 0; i < loginAddressMax; i++ { g.Fail("user"+time.Duration(i).String(), "203.0.113.9") } if blocked, _ := g.Blocked("someone-new", "203.0.113.9"); !blocked { t.Fatal("address that sprayed many accounts should be blocked") } g.Succeed("someone-new") if blocked, _ := g.Blocked("someone-new", "203.0.113.9"); !blocked { t.Fatal("a success must not clear the address count") } }