Files
gpt-load/internal/control/auth_limiter_test.go
T

194 lines
6.6 KiB
Go

package control
import (
"sync"
"testing"
"time"
)
func TestAuthFailureLimiterMarksOnlyNewLockTransition(t *testing.T) {
limiter := newAuthFailureLimiter()
now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC)
limiter.now = func() time.Time { return now }
for attempt := 1; attempt < authFailureLimit; attempt++ {
decision := limiter.evaluate("192.0.2.10", false)
if decision.newlyLocked || decision.retryAfter != 0 {
t.Fatalf("attempt %d decision = %#v", attempt, decision)
}
}
locked := limiter.evaluate("192.0.2.10", false)
if !locked.newlyLocked || locked.retryAfter != authLockDuration {
t.Fatalf("lock decision = %#v", locked)
}
stillLocked := limiter.evaluate("192.0.2.10", false)
if stillLocked.newlyLocked || stillLocked.retryAfter <= 0 {
t.Fatalf("locked retry decision = %#v", stillLocked)
}
}
func TestAuthFailureLimiterLocksOnFifthFailure(t *testing.T) {
current := time.Date(2026, 7, 23, 0, 0, 0, 0, time.UTC)
limiter := newAuthFailureLimiter()
limiter.now = func() time.Time { return current }
for attempt := 1; attempt < authFailureLimit; attempt++ {
decision := limiter.evaluate("192.0.2.1", false)
if decision.authorized || decision.retryAfter != 0 {
t.Fatalf("failure %d decision = %#v, want unauthorized without retry", attempt, decision)
}
}
decision := limiter.evaluate("192.0.2.1", false)
if decision.authorized || decision.retryAfter != authLockDuration {
t.Fatalf("fifth failure decision = %#v, want unauthorized with retry %s", decision, authLockDuration)
}
}
func TestAuthFailureLimiterUsesRollingThirtyMinuteWindow(t *testing.T) {
firstFailure := time.Date(2026, 7, 23, 0, 0, 0, 0, time.UTC)
current := firstFailure
limiter := newAuthFailureLimiter()
limiter.now = func() time.Time { return current }
for minute := 0; minute < 4; minute++ {
current = firstFailure.Add(time.Duration(minute) * time.Minute)
if decision := limiter.evaluate("192.0.2.1", false); decision.retryAfter != 0 {
t.Fatalf("failure at %s decision = %#v, want no lock", current, decision)
}
}
current = firstFailure.Add(authFailureWindow)
decision := limiter.evaluate("192.0.2.1", false)
if decision.retryAfter != 0 {
t.Fatalf("failure at exact cutoff decision = %#v, want first failure expired", decision)
}
decision = limiter.evaluate("192.0.2.1", false)
if decision.retryAfter != authLockDuration {
t.Fatalf("next rolling-window failure decision = %#v, want lock", decision)
}
}
func TestAuthFailureLimiterSuccessBeforeThresholdClearsFailures(t *testing.T) {
current := time.Date(2026, 7, 23, 0, 0, 0, 0, time.UTC)
limiter := newAuthFailureLimiter()
limiter.now = func() time.Time { return current }
for attempt := 0; attempt < authFailureLimit-1; attempt++ {
limiter.evaluate("192.0.2.1", false)
}
if decision := limiter.evaluate("192.0.2.1", true); !decision.authorized || decision.retryAfter != 0 {
t.Fatalf("successful decision = %#v, want authorized", decision)
}
for attempt := 0; attempt < authFailureLimit-1; attempt++ {
if decision := limiter.evaluate("192.0.2.1", false); decision.retryAfter != 0 {
t.Fatalf("failure after success %d decision = %#v, want no lock", attempt+1, decision)
}
}
}
func TestAuthFailureLimiterValidCredentialClearsLockedPeer(t *testing.T) {
current := time.Date(2026, 7, 23, 0, 0, 0, 0, time.UTC)
limiter := newAuthFailureLimiter()
limiter.now = func() time.Time { return current }
for attempt := 0; attempt < authFailureLimit; attempt++ {
limiter.evaluate("192.0.2.1", false)
}
decision := limiter.evaluate("192.0.2.1", true)
if !decision.authorized || decision.retryAfter != 0 {
t.Fatalf("valid locked-peer decision = %#v, want authorized", decision)
}
if decision := limiter.evaluate("192.0.2.1", false); decision.authorized || decision.retryAfter != 0 {
t.Fatalf("failure after valid credential = %#v, want fresh unauthorized state", decision)
}
}
func TestAuthFailureLimiterUnlocksAfterThirtyMinutes(t *testing.T) {
current := time.Date(2026, 7, 23, 0, 0, 0, 0, time.UTC)
limiter := newAuthFailureLimiter()
limiter.now = func() time.Time { return current }
for attempt := 0; attempt < authFailureLimit; attempt++ {
limiter.evaluate("192.0.2.1", false)
}
current = current.Add(authLockDuration)
decision := limiter.evaluate("192.0.2.1", true)
if !decision.authorized || decision.retryAfter != 0 {
t.Fatalf("post-expiry decision = %#v, want authorized", decision)
}
}
func TestAuthFailureLimiterKeepsPeersIsolated(t *testing.T) {
current := time.Date(2026, 7, 23, 0, 0, 0, 0, time.UTC)
limiter := newAuthFailureLimiter()
limiter.now = func() time.Time { return current }
for attempt := 0; attempt < authFailureLimit; attempt++ {
limiter.evaluate("192.0.2.1", false)
}
if decision := limiter.evaluate("192.0.2.2", true); !decision.authorized || decision.retryAfter != 0 {
t.Fatalf("other peer decision = %#v, want authorized", decision)
}
if decision := limiter.evaluate("192.0.2.1", false); decision.authorized || decision.retryAfter <= 0 {
t.Fatalf("locked peer decision = %#v, want lock preserved", decision)
}
}
func TestAuthFailureLimiterLazilyRemovesExpiredEntries(t *testing.T) {
firstFailure := time.Date(2026, 7, 23, 0, 0, 0, 0, time.UTC)
current := firstFailure
limiter := newAuthFailureLimiter()
limiter.now = func() time.Time { return current }
limiter.evaluate("expired", false)
current = current.Add(time.Minute)
for attempt := 0; attempt < authFailureLimit; attempt++ {
limiter.evaluate("locked", false)
}
current = firstFailure.Add(authFailureWindow)
limiter.evaluate("trigger", false)
if _, found := limiter.entries["expired"]; found {
t.Fatal("expired unlocked entry remains after lazy cleanup")
}
if _, found := limiter.entries["locked"]; !found {
t.Fatal("active locked entry removed before its lock expires")
}
current = firstFailure.Add(authFailureWindow + time.Minute)
limiter.evaluate("trigger", false)
if _, found := limiter.entries["locked"]; found {
t.Fatal("expired locked entry remains after lazy cleanup")
}
}
func TestAuthFailureLimiterConcurrentEvaluation(t *testing.T) {
current := time.Date(2026, 7, 23, 0, 0, 0, 0, time.UTC)
limiter := newAuthFailureLimiter()
limiter.now = func() time.Time { return current }
const evaluations = 32
var ready sync.WaitGroup
var done sync.WaitGroup
start := make(chan struct{})
ready.Add(evaluations)
done.Add(evaluations)
for index := 0; index < evaluations; index++ {
go func() {
defer done.Done()
ready.Done()
<-start
limiter.evaluate("192.0.2.1", false)
}()
}
ready.Wait()
close(start)
done.Wait()
decision := limiter.evaluate("192.0.2.1", false)
if decision.authorized || decision.retryAfter <= 0 {
t.Fatalf("concurrent final decision = %#v, want lock", decision)
}
}