Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
139 lines
3.9 KiB
Go
139 lines
3.9 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestLimiterAllowsFirstAttempts(t *testing.T) {
|
|
l := NewLimiter(nil)
|
|
for i := 0; i < 3; i++ {
|
|
if _, ok := l.Allow("mmueller", "10.0.0.1"); !ok {
|
|
t.Fatalf("Versuch %d muss erlaubt sein", i+1)
|
|
}
|
|
l.RecordFailure("mmueller", "10.0.0.1")
|
|
}
|
|
if _, ok := l.Allow("mmueller", "10.0.0.1"); ok {
|
|
t.Fatal("nach 3 Fehlversuchen muss gebremst werden")
|
|
}
|
|
}
|
|
|
|
func TestBackoffGrowsAndExpires(t *testing.T) {
|
|
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
|
|
l := NewLimiter(func() time.Time { return now })
|
|
|
|
for i := 0; i < 4; i++ {
|
|
l.RecordFailure("mmueller", "10.0.0.1")
|
|
}
|
|
wait1, ok := l.Allow("mmueller", "10.0.0.1")
|
|
if ok {
|
|
t.Fatal("nach 4 Fehlversuchen muss gebremst werden")
|
|
}
|
|
if wait1 <= 0 {
|
|
t.Fatalf("retryAfter = %v, muss positiv sein", wait1)
|
|
}
|
|
|
|
// Wartezeit absitzen -> wieder erlaubt.
|
|
now = now.Add(wait1 + time.Second)
|
|
if _, ok := l.Allow("mmueller", "10.0.0.1"); !ok {
|
|
t.Fatal("nach Ablauf der Wartezeit muss wieder erlaubt sein")
|
|
}
|
|
|
|
// Weiterer Fehlversuch -> längere Wartezeit als vorher.
|
|
l.RecordFailure("mmueller", "10.0.0.1")
|
|
wait2, ok := l.Allow("mmueller", "10.0.0.1")
|
|
if ok {
|
|
t.Fatal("erneuter Fehlversuch muss wieder bremsen")
|
|
}
|
|
if wait2 <= wait1 {
|
|
t.Errorf("Backoff muss wachsen: %v -> %v", wait1, wait2)
|
|
}
|
|
}
|
|
|
|
func TestBackoffIsCapped(t *testing.T) {
|
|
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
|
|
l := NewLimiter(func() time.Time { return now })
|
|
for i := 0; i < 40; i++ {
|
|
l.RecordFailure("mmueller", "10.0.0.1")
|
|
}
|
|
wait, ok := l.Allow("mmueller", "10.0.0.1")
|
|
if ok {
|
|
t.Fatal("muss gebremst sein")
|
|
}
|
|
if wait > maxBackoff {
|
|
t.Errorf("retryAfter = %v, Deckel ist %v", wait, maxBackoff)
|
|
}
|
|
}
|
|
|
|
func TestIPLimitProtectsAgainstUserSpray(t *testing.T) {
|
|
l := NewLimiter(nil)
|
|
// Angreifer probiert viele verschiedene Benutzernamen von einer IP.
|
|
for i := 0; i < 12; i++ {
|
|
l.RecordFailure(fmt.Sprintf("user%d", i), "10.0.0.9")
|
|
}
|
|
if _, ok := l.Allow("nochNieProbiert", "10.0.0.9"); ok {
|
|
t.Fatal("die IP muss trotz wechselnder Benutzernamen gebremst werden")
|
|
}
|
|
}
|
|
|
|
func TestUserLimitProtectsAcrossIPs(t *testing.T) {
|
|
l := NewLimiter(nil)
|
|
// Verteilter Angriff auf einen Benutzer von vielen IPs.
|
|
for i := 0; i < 12; i++ {
|
|
l.RecordFailure("mmueller", fmt.Sprintf("10.0.1.%d", i))
|
|
}
|
|
if _, ok := l.Allow("mmueller", "192.168.99.99"); ok {
|
|
t.Fatal("der Benutzer muss auch von einer neuen IP gebremst werden")
|
|
}
|
|
}
|
|
|
|
func TestUsernameIsCaseInsensitive(t *testing.T) {
|
|
l := NewLimiter(nil)
|
|
for i := 0; i < 6; i++ {
|
|
l.RecordFailure("MMueller", "10.0.0.1")
|
|
}
|
|
// Andere Schreibweise darf den Zähler nicht umgehen.
|
|
if _, ok := l.Allow("mmueller", "192.168.5.5"); ok {
|
|
t.Fatal("Schreibweise des Benutzernamens darf das Limit nicht umgehen")
|
|
}
|
|
}
|
|
|
|
func TestSuccessResetsBothCounters(t *testing.T) {
|
|
l := NewLimiter(nil)
|
|
for i := 0; i < 5; i++ {
|
|
l.RecordFailure("mmueller", "10.0.0.1")
|
|
}
|
|
if _, ok := l.Allow("mmueller", "10.0.0.1"); ok {
|
|
t.Fatal("Vorbedingung: muss gebremst sein")
|
|
}
|
|
l.RecordSuccess("mmueller", "10.0.0.1")
|
|
if _, ok := l.Allow("mmueller", "10.0.0.1"); !ok {
|
|
t.Fatal("erfolgreiche Anmeldung muss die Zähler zurücksetzen")
|
|
}
|
|
}
|
|
|
|
func TestNoHardLockout(t *testing.T) {
|
|
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
|
|
l := NewLimiter(func() time.Time { return now })
|
|
for i := 0; i < 100; i++ {
|
|
l.RecordFailure("opfer", "10.0.0.1")
|
|
}
|
|
// Nach dem gedeckelten Backoff darf der Benutzer wieder anklopfen —
|
|
// sonst könnte ein Dritter Kollegen dauerhaft aussperren.
|
|
now = now.Add(maxBackoff + time.Second)
|
|
if _, ok := l.Allow("opfer", "10.0.0.1"); !ok {
|
|
t.Fatal("es darf keinen dauerhaften Lockout geben")
|
|
}
|
|
}
|
|
|
|
func TestGCDropsIdleEntries(t *testing.T) {
|
|
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
|
|
l := NewLimiter(func() time.Time { return now })
|
|
l.RecordFailure("mmueller", "10.0.0.1")
|
|
now = now.Add(2 * entryTTL)
|
|
l.GC()
|
|
if n := l.size(); n != 0 {
|
|
t.Fatalf("GC muss veraltete Einträge entfernen, size = %d", n)
|
|
}
|
|
}
|