package alerts import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "git.tornberg.me/go-otel/internal/features" ) func burst(sig string, n int, at time.Time, status int, route string) []features.Event { var out []features.Event for i := 0; i < n; i++ { out = append(out, features.Event{Timestamp: at.Add(time.Duration(i) * time.Second), Service: "api", Kind: "span", IsError: true, PatternSignature: sig, StatusCode: status, Route: route, ClientIP: "203.0.113.7"}) } return out } type fake struct { mu sync.Mutex sent []Alert err error } func (f *fake) Name() string { return "fake" } func (f *fake) Send(_ context.Context, a Alert) error { f.mu.Lock() defer f.mu.Unlock() f.sent = append(f.sent, a) return f.err } func newRunner(events []features.Event, n Notifier) *Runner { cfg := Config{Interval: time.Minute, Window: 5 * time.Minute, BaselineWindows: 12, Cooldown: 30 * time.Minute, MinExcess: 5, Multiple: 3, Lag: 0} return &Runner{ cfg: cfg, notifiers: []Notifier{n}, sup: features.NewSuppressor(cfg.Cooldown), load: func(context.Context, time.Time, time.Time) ([]features.Event, error) { return events, nil }, dataSince: func(context.Context) time.Time { return time.Time{} }, } } func TestRunnerPrimesThenAlertsOnceAndFiltersProbes(t *testing.T) { now := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC) cur := now.Add(-2 * time.Minute) f := &fake{} // a real defect burst and a probe burst in the same window events := append(burst("defect", 20, cur, 500, "/api/orders"), burst("probe", 50, cur, 404, "/.env")...) r := newRunner(events, f) r.Tick(context.Background(), now) // first tick primes: nothing is sent if len(f.sent) != 0 { t.Fatalf("primed tick sent %d alerts", len(f.sent)) } // the same incident keeps going: suppressed by the primed cooldown r.Tick(context.Background(), now.Add(time.Minute)) if len(f.sent) != 0 { t.Fatalf("ongoing incident re-alerted: %d", len(f.sent)) } // a new, different failure appears r.load = func(context.Context, time.Time, time.Time) ([]features.Event, error) { return append(events, burst("newdefect", 15, now.Add(time.Minute), 503, "/api/pay")...), nil } r.Tick(context.Background(), now.Add(4*time.Minute)) if len(f.sent) != 1 { t.Fatalf("want 1 alert, got %d", len(f.sent)) } txt := f.sent[0].Text("https://otel.example") if strings.Contains(txt, "/.env") { t.Fatalf("probe leaked into alert: %s", txt) } if !strings.Contains(txt, "/api/pay") || !strings.Contains(txt, "1 client") || !strings.HasSuffix(txt, "https://otel.example/issues") { t.Fatalf("unexpected text: %s", txt) } if got := r.Status(); len(got.Alerts) != 1 || got.Channels[0] != "fake" { t.Fatalf("status = %+v", got) } } func TestTelegramSendsAndNeverLeaksToken(t *testing.T) { var gotPath string var body map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path _ = json.NewDecoder(r.Body).Decode(&body) w.WriteHeader(200) })) tg := &Telegram{Token: "SECRETTOKEN", ChatID: "42", ThreadID: "7", API: srv.URL} a := NewAlert(features.Spike{Service: "s", ErrorCount: 9}, time.Now()) if err := tg.Send(context.Background(), a); err != nil { t.Fatal(err) } if gotPath != "/botSECRETTOKEN/sendMessage" || body["chat_id"] != "42" || body["message_thread_id"] != float64(7) { t.Fatalf("request wrong: %s %+v", gotPath, body) } srv.Close() // connection refused: the transport error contains the URL err := tg.Send(context.Background(), a) if err == nil || strings.Contains(err.Error(), "SECRETTOKEN") { t.Fatalf("token leaked or no error: %v", err) } bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"description":"bad token SECRETTOKEN"}`, 401) })) defer bad.Close() err = (&Telegram{Token: "SECRETTOKEN", ChatID: "1", API: bad.URL}).Send(context.Background(), a) if err == nil || strings.Contains(err.Error(), "SECRETTOKEN") || !strings.Contains(err.Error(), "401") { t.Fatalf("error not scrubbed: %v", err) } } func TestTelegramTruncates(t *testing.T) { var body map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewDecoder(r.Body).Decode(&body) })) defer srv.Close() spike := features.Spike{Service: "s"} for i := 0; i < 200; i++ { spike.Patterns = append(spike.Patterns, features.PatternDelta{Count: 1, Example: features.Event{Message: strings.Repeat("x", 100)}}) } if err := (&Telegram{Token: "t", ChatID: "1", API: srv.URL}).Send(context.Background(), NewAlert(spike, time.Now())); err != nil { t.Fatal(err) } if n := len([]rune(body["text"].(string))); n > 4096 { t.Fatalf("message too long: %d", n) } } func TestScannerClientIsMutedEntirely(t *testing.T) { at := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC) cur := at.Add(-2 * time.Minute) scanner := func(route string, n int) []features.Event { ev := burst("p-"+route, n, cur, 503, route) for i := range ev { ev[i].ClientIP = "198.51.100.9" } return ev } var events []features.Event for _, r := range []string{"/.env", "/.cursorrules", "/.codex/auth.json"} { events = append(events, scanner(r, 4)...) } events = append(events, scanner("/", 12)...) // the scanner's innocent-looking root request if got := DetectAlerts(events, at, features.SpikeConfig{Window: 5 * time.Minute, BaselineWindows: 12, MinExcess: 5}); len(got) != 0 { t.Fatalf("scanner traffic alerted: %+v", got) } // a different client with the same 503 on "/" still alerts other := burst("real-503", 12, cur, 503, "/") if got := DetectAlerts(other, at, features.SpikeConfig{Window: 5 * time.Minute, BaselineWindows: 12, MinExcess: 5}); len(got) != 1 { t.Fatalf("real failure was muted: %+v", got) } } func TestMinExcessAppliesAfterTriage(t *testing.T) { at := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC) cur := at.Add(-2 * time.Minute) // 200 probe errors + 6 real ones: total excess is large, actionable excess is 6 < 10 events := append(burst("probe", 200, cur, 404, "/.env"), burst("real", 6, cur, 503, "/api")...) if got := DetectAlerts(events, at, features.SpikeConfig{Window: 5 * time.Minute, BaselineWindows: 12, MinExcess: 10}); len(got) != 0 { t.Fatalf("paged on excess that was mostly probes: %+v", got) } }