Distinguish auth/lookup failures, make votes idempotent on visible questions, bound shutdown, page admin users, LRU throttle, trusted-proxy CIDRs, avatar cleanup, versioned migrations, and session cleanup logging.
128 lines
3.1 KiB
Go
128 lines
3.1 KiB
Go
package web
|
|
|
|
import (
|
|
"net"
|
|
"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 LRU-evict and accept 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) {
|
|
_, proxyNet, err := net.ParseCIDR("10.0.0.0/8")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := &Server{cfg: Config{TrustedProxies: []*net.IPNet{proxyNet}}}
|
|
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)
|
|
}
|
|
|
|
req.RemoteAddr = "203.0.113.50:9"
|
|
if got := srv.clientIP(req); got != "203.0.113.50" {
|
|
t.Fatalf("untrusted peer should ignore xff, 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")
|
|
}
|
|
}
|