init
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeClock drives the limiter's refill without sleeping.
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func (c *fakeClock) now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
}
|
||||
|
||||
func (c *fakeClock) advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.t = c.t.Add(d)
|
||||
}
|
||||
|
||||
func testLimiter(t *testing.T, burst int, period time.Duration, max int) (*Limiter, *fakeClock) {
|
||||
t.Helper()
|
||||
l := NewLimiter(burst, period, max)
|
||||
clk := &fakeClock{t: time.Unix(1_700_000_000, 0)}
|
||||
l.now = clk.now
|
||||
return l, clk
|
||||
}
|
||||
|
||||
// Successful requests must cost nothing: a CI runner pushing hundreds of valid
|
||||
// deploys a second is not the thing this limiter is defending against.
|
||||
func TestAllowDoesNotConsume(t *testing.T) {
|
||||
l, _ := testLimiter(t, 3, time.Minute, 100)
|
||||
for i := 0; i < 1000; i++ {
|
||||
if !l.Allow("10.0.0.1") {
|
||||
t.Fatalf("Allow denied a caller that never failed (i=%d)", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailExhaustsBudget(t *testing.T) {
|
||||
l, _ := testLimiter(t, 3, time.Minute, 100)
|
||||
const ip = "10.0.0.1"
|
||||
for i := 0; i < 3; i++ {
|
||||
if !l.Allow(ip) {
|
||||
t.Fatalf("denied before the burst was spent (i=%d)", i)
|
||||
}
|
||||
l.Fail(ip)
|
||||
}
|
||||
if l.Allow(ip) {
|
||||
t.Error("burst exhausted but the caller is still allowed")
|
||||
}
|
||||
// Failing while already throttled must not push the balance negative, or
|
||||
// the client would take proportionally longer to recover the more it tried.
|
||||
for i := 0; i < 100; i++ {
|
||||
l.Fail(ip)
|
||||
}
|
||||
if d := l.RetryAfter(ip); d > 2*time.Minute {
|
||||
t.Errorf("RetryAfter = %v; over-failing drove the bucket negative", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBudgetRefills(t *testing.T) {
|
||||
l, clk := testLimiter(t, 4, time.Minute, 100)
|
||||
const ip = "10.0.0.1"
|
||||
for i := 0; i < 4; i++ {
|
||||
l.Fail(ip)
|
||||
}
|
||||
if l.Allow(ip) {
|
||||
t.Fatal("expected to be throttled")
|
||||
}
|
||||
|
||||
// One quarter of the period restores one of four tokens.
|
||||
clk.advance(15 * time.Second)
|
||||
if !l.Allow(ip) {
|
||||
t.Error("no token after a quarter period")
|
||||
}
|
||||
|
||||
clk.advance(time.Hour)
|
||||
for i := 0; i < 4; i++ {
|
||||
if !l.Allow(ip) {
|
||||
t.Fatalf("bucket did not refill to full (i=%d)", i)
|
||||
}
|
||||
l.Fail(ip)
|
||||
}
|
||||
if l.Allow(ip) {
|
||||
t.Error("bucket refilled past its burst")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientsAreIndependent(t *testing.T) {
|
||||
l, _ := testLimiter(t, 2, time.Minute, 100)
|
||||
for i := 0; i < 2; i++ {
|
||||
l.Fail("10.0.0.1")
|
||||
}
|
||||
if l.Allow("10.0.0.1") {
|
||||
t.Error("attacker not throttled")
|
||||
}
|
||||
if !l.Allow("10.0.0.2") {
|
||||
t.Error("one bad client throttled an unrelated one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryAfter(t *testing.T) {
|
||||
l, clk := testLimiter(t, 2, time.Minute, 100)
|
||||
const ip = "10.0.0.1"
|
||||
if d := l.RetryAfter(ip); d != 0 {
|
||||
t.Errorf("RetryAfter with budget left = %v, want 0", d)
|
||||
}
|
||||
l.Fail(ip)
|
||||
l.Fail(ip)
|
||||
|
||||
d := l.RetryAfter(ip)
|
||||
if d <= 0 {
|
||||
t.Fatal("a throttled client must be told to wait a positive time")
|
||||
}
|
||||
if d > 2*time.Minute {
|
||||
t.Errorf("RetryAfter = %v, unreasonably long for a 1m period", d)
|
||||
}
|
||||
// Waiting the advertised time must actually be enough.
|
||||
clk.advance(d)
|
||||
if !l.Allow(ip) {
|
||||
t.Errorf("still throttled after waiting the advertised %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// The bucket map is keyed by attacker-chosen input, so its bound is load
|
||||
// bearing: without it the rate limiter becomes the memory exhaustion vector.
|
||||
func TestBucketMapIsBounded(t *testing.T) {
|
||||
l, _ := testLimiter(t, 2, time.Minute, 64)
|
||||
for i := 0; i < 10000; i++ {
|
||||
l.Fail(fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff))
|
||||
}
|
||||
if got := l.buckets.Len(); got > 64 {
|
||||
t.Errorf("tracking %d clients, cap is 64", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A nil limiter is the "rate limiting disabled" configuration and must not
|
||||
// panic in the request path.
|
||||
func TestNilLimiterAllowsEverything(t *testing.T) {
|
||||
var l *Limiter
|
||||
if !l.Allow("10.0.0.1") {
|
||||
t.Error("nil limiter denied a request")
|
||||
}
|
||||
l.Fail("10.0.0.1")
|
||||
if d := l.RetryAfter("10.0.0.1"); d != 0 {
|
||||
t.Errorf("RetryAfter = %v, want 0", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiterConcurrentUse(t *testing.T) {
|
||||
l := NewLimiter(50, time.Minute, 256)
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < 16; g++ {
|
||||
wg.Add(1)
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 200; i++ {
|
||||
ip := fmt.Sprintf("10.0.0.%d", i%8)
|
||||
l.Allow(ip)
|
||||
if i%3 == 0 {
|
||||
l.Fail(ip)
|
||||
}
|
||||
l.RetryAfter(ip)
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
Reference in New Issue
Block a user