package web import ( "net/http" "net/http/httptest" "sync" "testing" "time" ) func TestThrottleWindowAndEviction(t *testing.T) { th := newThrottle(2, 50*time.Millisecond, 100) if !th.allow("a") || !th.allow("a") { t.Fatal("first two should pass") } if th.allow("a") { t.Fatal("third within window should fail") } time.Sleep(60 * time.Millisecond) if !th.allow("a") { t.Fatal("after window should pass") } // Expired empty keys should be removed on next allow of another key path. time.Sleep(60 * time.Millisecond) _ = th.allow("b") if th.lenKeys() > 2 { t.Fatalf("expected eviction of stale keys, got %d", th.lenKeys()) } } func TestThrottleMaxKeys(t *testing.T) { th := newThrottle(5, time.Minute, 2) if !th.allow("one") || !th.allow("two") { t.Fatal("first keys should pass") } if th.allow("three") { t.Fatal("over maxKeys should reject new key") } if th.lenKeys() != 2 { t.Fatalf("keys=%d want 2", th.lenKeys()) } } func TestThrottleConcurrent(t *testing.T) { th := newThrottle(50, time.Minute, 1000) var wg sync.WaitGroup var okCount int var mu sync.Mutex for i := 0; i < 100; i++ { wg.Add(1) go func() { defer wg.Done() if th.allow("same") { mu.Lock() okCount++ mu.Unlock() } }() } wg.Wait() if okCount != 50 { t.Fatalf("ok=%d want 50", okCount) } } func TestFailureTrackerProgressiveAndClear(t *testing.T) { f := newFailureTracker(time.Minute, 100) if d := f.delay("k"); d != 0 { t.Fatalf("fresh delay=%v", d) } f.record("k") f.record("k") if d := f.delay("k"); d != 200*time.Millisecond { t.Fatalf("delay after 2 fails=%v", d) } f.clear("k") if d := f.delay("k"); d != 0 { t.Fatalf("after clear delay=%v", d) } } func TestFailureTrackerEvictsExpired(t *testing.T) { f := newFailureTracker(30*time.Millisecond, 100) f.record("old") time.Sleep(40 * time.Millisecond) _ = f.delay("other") // triggers eviction if f.lenKeys() != 0 { t.Fatalf("expired key remained, keys=%d", f.lenKeys()) } } func TestClientIPTrustProxy(t *testing.T) { srv := &Server{cfg: Config{TrustProxy: true}} req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "10.0.0.1:1234" req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1") if got := srv.clientIP(req); got != "203.0.113.9" { t.Fatalf("trusted xff got %q", got) } srv.cfg.TrustProxy = false if got := srv.clientIP(req); got != "10.0.0.1" { t.Fatalf("untrusted should use RemoteAddr host, got %q", got) } } func TestNoGlobalUsernameHardLockout(t *testing.T) { // Victim IP should still be allowed after another IP burns attempts for the same username. srv := &Server{ loginIP: newThrottle(20, time.Minute, 100), loginFail: newFailureTracker(time.Minute, 100), } for i := 0; i < 20; i++ { srv.loginFail.record(loginFailKey("1.1.1.1", "alice")) } victim := httptest.NewRequest(http.MethodPost, "/login", nil) victim.RemoteAddr = "2.2.2.2:9" w := httptest.NewRecorder() if !srv.allowLoginAttempt(w, victim, "alice") { t.Fatal("victim IP must not be hard-locked by username-only attempts") } }