package ratelimit import ( "testing" "time" ) // banlistAt builds an enabled banlist whose clock the test drives through clk. func banlistAt(clk *time.Time, cfg BanConfig) *Banlist { bl := NewBanlist(cfg) bl.now = func() time.Time { return *clk } return bl } func enabledCfg() BanConfig { return BanConfig{Enabled: true, Threshold: 3, Window: time.Minute, Duration: 15 * time.Minute} } func TestBanlistStrikeThreshold(t *testing.T) { clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) bl := banlistAt(&clk, enabledCfg()) if bl.Strike("1.2.3.4") { t.Fatal("first strike must not ban") } if bl.Strike("1.2.3.4") { t.Fatal("second strike must not ban") } if bl.Banned("1.2.3.4") { t.Fatal("must not be banned before the third strike") } if !bl.Strike("1.2.3.4") { t.Fatal("third strike within the window must ban") } if !bl.Banned("1.2.3.4") { t.Fatal("must be banned after the threshold strike") } } func TestBanlistStrikeWindowResets(t *testing.T) { clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) bl := banlistAt(&clk, enabledCfg()) // Strikes spaced wider than the window never accumulate to a ban. for i := range 5 { if bl.Strike("9.9.9.9") { t.Fatalf("strike %d should not ban: each falls outside the previous window", i) } clk = clk.Add(2 * time.Minute) } if bl.Banned("9.9.9.9") { t.Fatal("strikes outside the rolling window must not ban") } } func TestBanlistBanExpires(t *testing.T) { clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) bl := banlistAt(&clk, enabledCfg()) bl.BanNow("5.5.5.5", ReasonRejections) if !bl.Banned("5.5.5.5") { t.Fatal("must be banned right after BanNow") } clk = clk.Add(15*time.Minute + time.Second) if bl.Banned("5.5.5.5") { t.Fatal("ban must lapse after its duration") } } func TestBanlistReasonDurations(t *testing.T) { clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) bl := banlistAt(&clk, enabledCfg()) bl.BanNow("a", ReasonTripwire) // 1h bl.BanNow("b", ReasonHoneytoken) // 24h clk = clk.Add(90 * time.Minute) if bl.Banned("a") { t.Fatal("tripwire ban (1h) must have lapsed after 90m") } if !bl.Banned("b") { t.Fatal("honeytoken ban (24h) must still hold after 90m") } } func TestBanlistUnban(t *testing.T) { clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) bl := banlistAt(&clk, enabledCfg()) bl.BanNow("7.7.7.7", ReasonTripwire) bl.Unban("7.7.7.7") if bl.Banned("7.7.7.7") { t.Fatal("Unban must clear the ban") } } func TestBanlistActiveSnapshot(t *testing.T) { clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) bl := banlistAt(&clk, enabledCfg()) bl.BanNow("a", ReasonTripwire) bl.BanNow("b", ReasonHoneytoken) active := bl.Active() if len(active) != 2 { t.Fatalf("Active = %d bans, want 2", len(active)) } byIP := map[string]Ban{} for _, b := range active { byIP[b.IP] = b } if byIP["a"].Reason != ReasonTripwire || byIP["b"].Reason != ReasonHoneytoken { t.Fatalf("Active reasons = %+v", byIP) } if !byIP["a"].Expires.After(byIP["a"].Since) { t.Fatal("Expires must be after Since") } clk = clk.Add(2 * time.Hour) // tripwire (1h) lapses, honeytoken (24h) holds if got := len(bl.Active()); got != 1 { t.Fatalf("Active after 2h = %d, want 1 (only honeytoken)", got) } } func TestBanlistDisabledIsInert(t *testing.T) { clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) cfg := enabledCfg() cfg.Enabled = false bl := banlistAt(&clk, cfg) for range 10 { if bl.Strike("1.1.1.1") { t.Fatal("disabled banlist must never ban via Strike") } } if bl.BanNow("2.2.2.2", ReasonHoneytoken) { t.Fatal("disabled banlist must never ban via BanNow") } if bl.Banned("1.1.1.1") || bl.Banned("2.2.2.2") { t.Fatal("disabled banlist must report nothing banned") } if len(bl.Active()) != 0 { t.Fatal("disabled banlist must expose no active bans") } }