158 lines
6.3 KiB
Go
158 lines
6.3 KiB
Go
package features
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestDetectSpikesIgnoresChronicPatterns(t *testing.T) {
|
|
at := time.Date(2026, 9, 19, 12, 0, 0, 0, time.UTC)
|
|
cfg := SpikeConfig{Window: 5 * time.Minute, BaselineWindows: 12}
|
|
var events []Event
|
|
add := func(sig string, n int, when time.Time) {
|
|
for i := 0; i < n; i++ {
|
|
events = append(events, Event{Timestamp: when.Add(time.Duration(i) * time.Second), Service: "api", IsError: true, PatternSignature: sig})
|
|
}
|
|
}
|
|
// chronic poller: 50 errors in every window, including the current one
|
|
for w := 0; w <= 12; w++ {
|
|
add("poll-401", 50, at.Add(-time.Duration(w+1)*cfg.Window))
|
|
}
|
|
if got := DetectSpikes(events, at, cfg); len(got) != 0 {
|
|
t.Fatalf("chronic pattern alone alerted: %+v", got)
|
|
}
|
|
// a novel failure appears in the current window
|
|
add("db-timeout", 20, at.Add(-cfg.Window))
|
|
got := DetectSpikes(events, at, cfg)
|
|
if len(got) != 1 || got[0].Patterns[0].Signature != "db-timeout" || !got[0].Patterns[0].Novel {
|
|
t.Fatalf("expected db-timeout spike, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestDetectSpikesRequiresMinimumExcess(t *testing.T) {
|
|
at := time.Date(2026, 9, 19, 12, 0, 0, 0, time.UTC)
|
|
events := []Event{}
|
|
for i := 0; i < 3; i++ {
|
|
events = append(events, Event{Timestamp: at.Add(-time.Minute), Service: "api", IsError: true, PatternSignature: "x"})
|
|
}
|
|
if got := DetectSpikes(events, at, SpikeConfig{}); len(got) != 0 {
|
|
t.Fatalf("3 errors should stay under the floor: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestBaselineTrimIgnoresEarlierStorm(t *testing.T) {
|
|
at := time.Date(2026, 9, 19, 12, 0, 0, 0, time.UTC)
|
|
cfg := SpikeConfig{Window: 5 * time.Minute, BaselineWindows: 10}
|
|
var events []Event
|
|
add := func(sig string, n int, when time.Time) {
|
|
for i := 0; i < n; i++ {
|
|
events = append(events, Event{Timestamp: when.Add(time.Duration(i) * time.Millisecond), Service: "api", IsError: true, PatternSignature: sig})
|
|
}
|
|
}
|
|
// one earlier storm in 1 of 10 baseline windows, then a smaller repeat now
|
|
add("storm", 1000, at.Add(-6*cfg.Window))
|
|
add("storm", 60, at.Add(-cfg.Window))
|
|
got := DetectSpikes(events, at, cfg)
|
|
if len(got) != 1 || got[0].Patterns[0].BaselineMean != 0 {
|
|
t.Fatalf("storm baseline should be trimmed to 0, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestSuppressorCooldownAndEscalation(t *testing.T) {
|
|
at := time.Date(2026, 9, 19, 12, 0, 0, 0, time.UTC)
|
|
spike := func(at time.Time, count int) []Spike {
|
|
return []Spike{{Service: "api", WindowEnd: at, Patterns: []PatternDelta{{Signature: "x", Count: count, Excess: float64(count)}}}}
|
|
}
|
|
s := NewSuppressor(30 * time.Minute)
|
|
if len(s.Filter(spike(at, 20))) != 1 {
|
|
t.Fatal("first alert should pass")
|
|
}
|
|
if len(s.Filter(spike(at.Add(5*time.Minute), 22))) != 0 {
|
|
t.Fatal("repeat inside cooldown should be suppressed")
|
|
}
|
|
if len(s.Filter(spike(at.Add(10*time.Minute), 45))) != 1 {
|
|
t.Fatal("2x escalation should re-alert")
|
|
}
|
|
if len(s.Filter(spike(at.Add(50*time.Minute), 46))) != 1 {
|
|
t.Fatal("alert after cooldown should pass")
|
|
}
|
|
}
|
|
|
|
func TestDetectSpikesWarmupGuard(t *testing.T) {
|
|
at := time.Date(2026, 9, 19, 12, 0, 0, 0, time.UTC)
|
|
cfg := SpikeConfig{Window: 5 * time.Minute, BaselineWindows: 12, WarmupWindows: 6}
|
|
var events []Event
|
|
for i := 0; i < 20; i++ {
|
|
events = append(events, Event{Timestamp: at.Add(-time.Minute).Add(time.Duration(i) * time.Second), Service: "api", IsError: true, PatternSignature: "new"})
|
|
}
|
|
cfg.DataSince = at.Add(-10 * time.Minute) // data began 10 min ago: cold
|
|
if got := DetectSpikes(events, at, cfg); len(got) != 0 {
|
|
t.Fatalf("alerted during warm-up: %+v", got)
|
|
}
|
|
cfg.DataSince = at.Add(-2 * time.Hour) // warm
|
|
if got := DetectSpikes(events, at, cfg); len(got) != 1 {
|
|
t.Fatalf("expected alert once warm, got %+v", got)
|
|
}
|
|
cfg.DataSince = time.Time{} // guard disabled
|
|
if got := DetectSpikes(events, at, cfg); len(got) != 1 {
|
|
t.Fatalf("guard should be off when DataSince is zero")
|
|
}
|
|
}
|
|
|
|
func TestDetectSpikesSkipsDuplicateHopsAndCountsClients(t *testing.T) {
|
|
at := time.Date(2026, 9, 19, 12, 0, 0, 0, time.UTC)
|
|
cfg := SpikeConfig{Window: 5 * time.Minute, BaselineWindows: 12}
|
|
var events []Event
|
|
for i := 0; i < 8; i++ {
|
|
ts := at.Add(-time.Minute).Add(time.Duration(i) * time.Second)
|
|
client := "203.0.113." + string(rune('1'+i%2))
|
|
events = append(events,
|
|
Event{Timestamp: ts, Service: "nginx", IsError: true, PatternSignature: "p", ClientIP: client},
|
|
// two more hops of the same request must not count
|
|
Event{Timestamp: ts, Service: "nginx", IsError: true, PatternSignature: "p", ClientIP: client, IsDuplicate: true},
|
|
Event{Timestamp: ts, Service: "nginx", IsError: true, PatternSignature: "p", ClientIP: client, IsDuplicate: true},
|
|
)
|
|
}
|
|
got := DetectSpikes(events, at, cfg)
|
|
if len(got) != 1 || got[0].ErrorCount != 8 || got[0].Patterns[0].Count != 8 {
|
|
t.Fatalf("duplicates were counted: %+v", got)
|
|
}
|
|
if got[0].Patterns[0].Clients != 2 {
|
|
t.Fatalf("clients = %d, want 2", got[0].Patterns[0].Clients)
|
|
}
|
|
}
|
|
|
|
func TestMarkBadActors(t *testing.T) {
|
|
at := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC)
|
|
ev := func(ip, route string, status int, class string) Event {
|
|
return Event{Timestamp: at, Service: "nginx", IsError: true, ClientIP: ip, Route: route, StatusCode: status, UAClass: class}
|
|
}
|
|
var events []Event
|
|
for _, r := range []string{"/.env", "/.cursorrules", "/.codex/auth.json"} { // probe scanner
|
|
events = append(events, ev("198.51.100.1", r, 404, "browser"))
|
|
}
|
|
events = append(events, ev("198.51.100.1", "/", 503, "browser")) // its innocent request
|
|
for i := 0; i < 15; i++ { // enumerator of ordinary-looking paths
|
|
events = append(events, ev("198.51.100.2", "/api/x"+string(rune('a'+i)), 404, "browser"))
|
|
}
|
|
events = append(events,
|
|
ev("203.0.113.5", "/missing", 404, "ai_crawler"), // declared crawler 4xx: bad actor
|
|
ev("203.0.113.5", "/api/orders", 500, "ai_crawler"), // ...but its 5xx is a real defect
|
|
ev("203.0.113.6", "/api/orders", 500, "browser"),
|
|
ev("203.0.113.7", "/missing", 404, "library"), // libraries are not crawlers
|
|
)
|
|
MarkBadActors(events, BadActorConfig{})
|
|
got := map[string]int{}
|
|
for _, e := range events {
|
|
got[e.BadActor]++
|
|
}
|
|
if got[BadActorProbe] != 4 || got[BadActorEnumerator] != 15 || got[BadActorBot] != 1 || got[""] != 3 {
|
|
t.Fatalf("marking wrong: %+v", got)
|
|
}
|
|
for _, e := range events {
|
|
if e.ClientIP == "203.0.113.5" && e.StatusCode == 500 && e.BadActor != "" {
|
|
t.Fatal("a crawler-provoked 5xx must stay visible")
|
|
}
|
|
}
|
|
}
|