177 lines
4.1 KiB
Go
177 lines
4.1 KiB
Go
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()
|
|
}
|