a73x

internal/server/api/ratelimit_test.go

Ref:   Size: 5.5 KiB   History

package api

import (
	"fmt"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// TestLimiterRefillAndCap pins the token-bucket math against a fake clock:
// a drained bucket refills one token per enrollRefillEvery, capped at burst.
func TestLimiterRefillAndCap(t *testing.T) {
	now := time.Unix(1_750_000_000, 0)
	l := newIPLimiter(func() time.Time { return now })

	for i := range enrollBurst {
		assert.True(t, l.allow("10.0.0.1"), "burst request %d", i)
	}
	assert.False(t, l.allow("10.0.0.1"), "burst exhausted")

	now = now.Add(enrollRefillEvery)
	assert.True(t, l.allow("10.0.0.1"), "one token refilled")
	assert.False(t, l.allow("10.0.0.1"), "only one refilled")

	// A long idle period refills to the cap, not beyond.
	now = now.Add(24 * time.Hour)
	for i := range enrollBurst {
		assert.True(t, l.allow("10.0.0.1"), "cap request %d", i)
	}
	assert.False(t, l.allow("10.0.0.1"), "cap enforced")
}

// TestLimiterPruneAndFailOpen pins the capacity behavior: at the entry cap,
// idle buckets are pruned to admit new IPs; when nothing is prunable the
// limiter fails OPEN (it is a brake, not the auth boundary).
func TestLimiterPruneAndFailOpen(t *testing.T) {
	now := time.Unix(1_750_000_000, 0)
	l := newIPLimiter(func() time.Time { return now })

	for i := range maxLimiterEntries {
		l.allow(fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff))
	}
	assert.Len(t, l.buckets, maxLimiterEntries)

	// Nothing is idle yet: a new IP must still be allowed (fail open) and
	// must not grow the map.
	assert.True(t, l.allow("192.0.2.1"), "fail open when full and nothing prunable")
	assert.Len(t, l.buckets, maxLimiterEntries, "fail-open must not grow the map")

	// After everything goes idle past a full refill, pruning admits new IPs.
	now = now.Add(time.Duration(enrollBurst)*enrollRefillEvery + time.Second)
	assert.True(t, l.allow("192.0.2.2"))
	assert.Len(t, l.buckets, 1, "idle buckets pruned, new bucket tracked")
}

// The three tests below pin the limiter's numbers against literals. The rest of
// this file is written in terms of enrollBurst, enrollRefillEvery and
// maxLimiterEntries — correct as construction, but it means the whole file
// passes with the refill at 30ms and the entry cap at 10, which is a limiter
// that limits nothing.

func TestEnrollBurstIsFiveRequests(t *testing.T) {
	now := time.Unix(1_750_000_000, 0)
	l := newIPLimiter(func() time.Time { return now })

	allowed := 0
	for range 100 {
		if !l.allow("10.0.0.1") {
			break
		}
		allowed++
	}

	assert.Equal(t, 5, allowed,
		"POST /api/v1/enroll is the plane's only unauthenticated endpoint, and this burst is how many enrollment tokens one "+
			"address may guess before it has to wait. Enrollment is operator-paced — one paste per host — so five covers a "+
			"fat-fingered retry; raise it and token-guessing gets cheaper by exactly that factor, drop it to zero and no "+
			"host can ever enroll")
}

func TestEnrollRefillTakesThirtySeconds(t *testing.T) {
	now := time.Unix(1_750_000_000, 0)
	l := newIPLimiter(func() time.Time { return now })
	for range enrollBurst {
		require.True(t, l.allow("10.0.0.1"))
	}
	require.False(t, l.allow("10.0.0.1"), "burst exhausted")

	now = now.Add(29 * time.Second)
	assert.False(t, l.allow("10.0.0.1"),
		"a token must take a full 30 seconds to drip back. The burst is only a brake if the wait after it costs an attacker "+
			"real time: at a refill of milliseconds the bucket is always full and the limiter is decoration")

	now = now.Add(time.Second)
	assert.True(t, l.allow("10.0.0.1"),
		"and no longer than 30 seconds: an operator enrolling a rack should not be made to wait minutes between hosts")
}

func TestLimiterTracksTenThousandAddresses(t *testing.T) {
	now := time.Unix(1_750_000_000, 0)
	l := newIPLimiter(func() time.Time { return now })

	for i := range 10_000 {
		l.allow(fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff))
	}

	assert.Len(t, l.buckets, 10_000,
		"the bucket map must hold ten thousand live addresses before it starts pruning. The cap is there so a spoofed-source "+
			"flood cannot grow the map without bound, but it is also how many distinct clients the limiter can brake at once: "+
			"set it low and the eleventh address evicts a real one, so a flood of fresh sources prunes the buckets holding "+
			"the actual attacker and the requests that matter fail open")
}

// TestBucketKeyGroupsIPv6BySlash64 pins the bucket-key normalization: IPv4
// (and v4-mapped-v6) keep per-address buckets; IPv6 collapses to the /64 so a
// single host's billions of addresses share one bucket; garbage stays raw.
func TestBucketKeyGroupsIPv6BySlash64(t *testing.T) {
	assert.Equal(t, "203.0.113.9", bucketKey("203.0.113.9"))
	assert.Equal(t, "203.0.113.9", bucketKey("::ffff:203.0.113.9"), "v4-mapped stays per-address")
	a := bucketKey("2001:db8:1:2:aaaa:bbbb:cccc:dddd")
	b := bucketKey("2001:db8:1:2:1111:2222:3333:4444")
	c := bucketKey("2001:db8:1:3::1")
	assert.Equal(t, a, b, "same /64 must share a bucket")
	assert.NotEqual(t, a, c, "different /64 must not")
	assert.Equal(t, "not-an-ip", bucketKey("not-an-ip"))
}

// TestLimiterSharesBucketAcrossSameSlash64 pins end behavior: burst spent
// from one v6 address exhausts the whole /64.
func TestLimiterSharesBucketAcrossSameSlash64(t *testing.T) {
	now := time.Unix(1_750_000_000, 0)
	l := newIPLimiter(func() time.Time { return now })
	for range enrollBurst {
		assert.True(t, l.allow(bucketKey("2001:db8::1")))
	}
	assert.False(t, l.allow(bucketKey("2001:db8::2")), "same /64: bucket shared")
	assert.True(t, l.allow(bucketKey("2001:db9::1")), "different /64: fresh bucket")
}