package auth import ( "context" "testing" "time" ) func TestRateLimiter_SlidingWindow(t *testing.T) { rdb, _ := newMiniRedis(t) base := time.Unix(1_700_000_000, 0) clock := base rl := NewRateLimiter(rdb, func() time.Time { return clock }) ctx := context.Background() // limit 1 per minute. ok, _, err := rl.Allow(ctx, "code:email", "a@b.com", 1, time.Minute) if err != nil || !ok { t.Fatalf("first attempt should pass: ok=%v err=%v", ok, err) } // Second attempt within the window is denied with a Retry-After. ok, ra, err := rl.Allow(ctx, "code:email", "a@b.com", 1, time.Minute) if err != nil { t.Fatalf("err: %v", err) } if ok { t.Fatal("second attempt within window should be denied") } if ra <= 0 || ra > time.Minute { t.Fatalf("retry-after = %v, want (0, 1m]", ra) } // After the window passes, attempts are allowed again. clock = base.Add(61 * time.Second) ok, _, err = rl.Allow(ctx, "code:email", "a@b.com", 1, time.Minute) if err != nil || !ok { t.Fatalf("attempt after window should pass: ok=%v err=%v", ok, err) } } func TestRateLimiter_DistinctKeysIndependent(t *testing.T) { rdb, _ := newMiniRedis(t) rl := NewRateLimiter(rdb, nil) ctx := context.Background() if ok, _, _ := rl.Allow(ctx, "code:email", "a@b.com", 1, time.Minute); !ok { t.Fatal("a@b.com first should pass") } if ok, _, _ := rl.Allow(ctx, "code:email", "c@d.com", 1, time.Minute); !ok { t.Fatal("c@d.com first should pass (independent key)") } } func TestRateLimiter_FailureCounter(t *testing.T) { rdb, mr := newMiniRedis(t) rl := NewRateLimiter(rdb, nil) ctx := context.Background() for i := 1; i <= 3; i++ { n, err := rl.RecordFailure(ctx, "login", "a@b.com", 15*time.Minute) if err != nil { t.Fatalf("RecordFailure: %v", err) } if n != int64(i) { t.Fatalf("count = %d, want %d", n, i) } } count, ttl, err := rl.FailureCount(ctx, "login", "a@b.com") if err != nil { t.Fatalf("FailureCount: %v", err) } if count != 3 { t.Fatalf("count = %d, want 3", count) } if ttl <= 0 { t.Fatalf("ttl = %v, want > 0", ttl) } // Clear resets the counter. if err := rl.ClearFailures(ctx, "login", "a@b.com"); err != nil { t.Fatalf("ClearFailures: %v", err) } count, _, _ = rl.FailureCount(ctx, "login", "a@b.com") if count != 0 { t.Fatalf("count after clear = %d, want 0", count) } // Counter expires after its window. _, _ = rl.RecordFailure(ctx, "login", "x@y.com", 15*time.Minute) mr.FastForward(16 * time.Minute) count, _, _ = rl.FailureCount(ctx, "login", "x@y.com") if count != 0 { t.Fatalf("count after TTL = %d, want 0", count) } }