mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
552 lines
15 KiB
Go
552 lines
15 KiB
Go
package notifications
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
|
)
|
|
|
|
const legacyCamelAlertIdentifierField = "alertId"
|
|
|
|
func TestSendResolvedAppriseCLI(t *testing.T) {
|
|
manager := NewNotificationManager("")
|
|
defer manager.Stop()
|
|
|
|
var called bool
|
|
manager.appriseExec = func(ctx context.Context, path string, args []string) ([]byte, error) {
|
|
called = true
|
|
if path != "apprise" {
|
|
t.Fatalf("expected CLI path apprise, got %q", path)
|
|
}
|
|
if len(args) == 0 || args[len(args)-1] != "target-1" {
|
|
t.Fatalf("expected target to be passed, got %v", args)
|
|
}
|
|
if !containsArg(args, "-t") || !containsArg(args, "-b") {
|
|
t.Fatalf("expected title/body args, got %v", args)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
config := AppriseConfig{
|
|
Enabled: true,
|
|
Mode: AppriseModeCLI,
|
|
Targets: []string{"target-1"},
|
|
CLIPath: "apprise",
|
|
TimeoutSeconds: 1,
|
|
}
|
|
|
|
alertList := []*alerts.Alert{
|
|
{
|
|
ID: "a1",
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelWarning,
|
|
ResourceID: "r1",
|
|
ResourceName: "db-1",
|
|
Message: "cpu high",
|
|
Value: 91,
|
|
Threshold: 80,
|
|
StartTime: time.Now().Add(-time.Minute),
|
|
},
|
|
}
|
|
|
|
if err := manager.sendResolvedApprise(config, alertList, time.Now()); err != nil {
|
|
t.Fatalf("sendResolvedApprise error: %v", err)
|
|
}
|
|
if !called {
|
|
t.Fatalf("expected apprise exec to be called")
|
|
}
|
|
}
|
|
|
|
func TestSendGroupedWebhookGeneric(t *testing.T) {
|
|
var gotMethod string
|
|
var gotBody []byte
|
|
|
|
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotMethod = r.Method
|
|
body, _ := io.ReadAll(r.Body)
|
|
gotBody = body
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
manager := NewNotificationManager("")
|
|
defer manager.Stop()
|
|
manager.webhookClient = server.Client()
|
|
if err := manager.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
|
|
t.Fatalf("allowlist: %v", err)
|
|
}
|
|
|
|
alertsList := []*alerts.Alert{
|
|
{
|
|
ID: "a1",
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelCritical,
|
|
ResourceID: "r1",
|
|
ResourceName: "db-1",
|
|
Message: "cpu critical",
|
|
Value: 99,
|
|
Threshold: 90,
|
|
StartTime: time.Now().Add(-2 * time.Minute),
|
|
},
|
|
{
|
|
ID: "a2",
|
|
Type: "mem",
|
|
Level: alerts.AlertLevelWarning,
|
|
ResourceID: "r2",
|
|
ResourceName: "cache-1",
|
|
Message: "memory high",
|
|
Value: 85,
|
|
Threshold: 80,
|
|
StartTime: time.Now().Add(-time.Minute),
|
|
},
|
|
}
|
|
|
|
webhook := WebhookConfig{
|
|
Name: "generic",
|
|
URL: server.URL + "/hook",
|
|
Enabled: true,
|
|
}
|
|
|
|
if err := manager.sendGroupedWebhook(webhook, alertsList); err != nil {
|
|
t.Fatalf("sendGroupedWebhook error: %v", err)
|
|
}
|
|
if gotMethod != http.MethodPost {
|
|
t.Fatalf("expected POST, got %s", gotMethod)
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(gotBody, &payload); err != nil {
|
|
t.Fatalf("unmarshal payload: %v", err)
|
|
}
|
|
if grouped, ok := payload["grouped"].(bool); !ok || !grouped {
|
|
t.Fatalf("expected grouped payload, got %v", payload["grouped"])
|
|
}
|
|
if count, ok := payload["count"].(float64); !ok || int(count) != len(alertsList) {
|
|
t.Fatalf("expected count %d, got %v", len(alertsList), payload["count"])
|
|
}
|
|
}
|
|
|
|
func TestSendGroupedWebhookDiscordEscapesSpecialCharacters(t *testing.T) {
|
|
var gotBody []byte
|
|
|
|
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
gotBody = body
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
manager := NewNotificationManager("https://pulse.example")
|
|
defer manager.Stop()
|
|
manager.webhookClient = server.Client()
|
|
if err := manager.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
|
|
t.Fatalf("failed to allowlist localhost: %v", err)
|
|
}
|
|
|
|
alert := &alerts.Alert{
|
|
ID: "alert-1",
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelWarning,
|
|
ResourceID: "vm-100",
|
|
ResourceName: `db "primary"`,
|
|
Node: `node\01`,
|
|
Message: "CPU spike on \"db\"\nPath C:\\temp",
|
|
Value: 92.3,
|
|
Threshold: 80,
|
|
StartTime: time.Now().Add(-5 * time.Minute),
|
|
}
|
|
|
|
webhook := WebhookConfig{
|
|
Name: "discord-test",
|
|
URL: server.URL,
|
|
Enabled: true,
|
|
Service: "discord",
|
|
}
|
|
|
|
if err := manager.sendGroupedWebhook(webhook, []*alerts.Alert{alert}); err != nil {
|
|
t.Fatalf("sendGroupedWebhook error: %v", err)
|
|
}
|
|
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal(gotBody, &payload); err != nil {
|
|
t.Fatalf("unmarshal payload: %v", err)
|
|
}
|
|
|
|
embeds, ok := payload["embeds"].([]interface{})
|
|
if !ok || len(embeds) == 0 {
|
|
t.Fatalf("expected embeds in payload, got: %v", payload)
|
|
}
|
|
|
|
embed, ok := embeds[0].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("expected embed object, got %T", embeds[0])
|
|
}
|
|
|
|
description, _ := embed["description"].(string)
|
|
if description != alert.Message {
|
|
t.Fatalf("expected description %q, got %q", alert.Message, description)
|
|
}
|
|
|
|
fields, ok := embed["fields"].([]interface{})
|
|
if !ok || len(fields) == 0 {
|
|
t.Fatalf("expected fields in embed, got: %v", embed)
|
|
}
|
|
|
|
resourceField, ok := fields[0].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("expected first field object, got %T", fields[0])
|
|
}
|
|
if resourceField["value"] != alert.ResourceName {
|
|
t.Fatalf("expected resource field %q, got %v", alert.ResourceName, resourceField["value"])
|
|
}
|
|
}
|
|
|
|
func TestSendResolvedWebhookHTTP(t *testing.T) {
|
|
var gotBody []byte
|
|
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
gotBody = body
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
manager := NewNotificationManager("")
|
|
defer manager.Stop()
|
|
manager.webhookClient = server.Client()
|
|
if err := manager.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
|
|
t.Fatalf("allowlist: %v", err)
|
|
}
|
|
|
|
alertList := []*alerts.Alert{
|
|
{
|
|
ID: "a1",
|
|
Type: "disk",
|
|
Level: alerts.AlertLevelWarning,
|
|
ResourceID: "r1",
|
|
ResourceName: "storage-1",
|
|
Message: "disk high",
|
|
Value: 92,
|
|
Threshold: 90,
|
|
StartTime: time.Now().Add(-time.Minute),
|
|
},
|
|
}
|
|
|
|
webhook := WebhookConfig{
|
|
Name: "resolved",
|
|
URL: server.URL + "/resolved",
|
|
Enabled: true,
|
|
}
|
|
|
|
if err := manager.sendResolvedWebhook(webhook, alertList, time.Now()); err != nil {
|
|
t.Fatalf("sendResolvedWebhook error: %v", err)
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(gotBody, &payload); err != nil {
|
|
t.Fatalf("unmarshal payload: %v", err)
|
|
}
|
|
if payload["event"] != "resolved" {
|
|
t.Fatalf("expected event resolved, got %v", payload["event"])
|
|
}
|
|
if payload["alertIdentifier"] != "a1" {
|
|
t.Fatalf("expected alertIdentifier a1, got %v", payload["alertIdentifier"])
|
|
}
|
|
if _, ok := payload[legacyCamelAlertIdentifierField]; ok {
|
|
t.Fatalf(
|
|
"did not expect legacy %s in resolved payload, got %v",
|
|
legacyCamelAlertIdentifierField,
|
|
payload[legacyCamelAlertIdentifierField],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestSendResolvedWebhookNtfySingleAlert(t *testing.T) {
|
|
var gotMethod string
|
|
var gotBody string
|
|
var gotHeaders http.Header
|
|
|
|
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotMethod = r.Method
|
|
gotHeaders = r.Header.Clone()
|
|
body, _ := io.ReadAll(r.Body)
|
|
gotBody = string(body)
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}))
|
|
defer server.Close()
|
|
|
|
manager := NewNotificationManager("")
|
|
defer manager.Stop()
|
|
manager.webhookClient = server.Client()
|
|
if err := manager.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
|
|
t.Fatalf("allowlist: %v", err)
|
|
}
|
|
|
|
resolvedAt := time.Date(2026, 2, 11, 12, 30, 0, 0, time.UTC)
|
|
alertList := []*alerts.Alert{
|
|
{
|
|
ID: "a1",
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelWarning,
|
|
ResourceID: "r1",
|
|
ResourceName: "db-1",
|
|
Node: "node-1",
|
|
Message: "cpu high",
|
|
Value: 91,
|
|
Threshold: 80,
|
|
StartTime: time.Now().Add(-time.Minute),
|
|
},
|
|
}
|
|
|
|
webhook := WebhookConfig{
|
|
Name: "resolved-ntfy",
|
|
URL: server.URL + "/ntfy",
|
|
Enabled: true,
|
|
Service: "ntfy",
|
|
Headers: map[string]string{
|
|
"X-Static": "keep-me",
|
|
"X-Template": "{{.ResourceName}}",
|
|
},
|
|
}
|
|
|
|
if err := manager.sendResolvedWebhookNtfy(webhook, alertList, resolvedAt); err != nil {
|
|
t.Fatalf("sendResolvedWebhookNtfy error: %v", err)
|
|
}
|
|
|
|
if gotMethod != http.MethodPost {
|
|
t.Fatalf("expected POST method by default, got %q", gotMethod)
|
|
}
|
|
if gotHeaders.Get("Content-Type") != "text/plain" {
|
|
t.Fatalf("expected text/plain content type, got %q", gotHeaders.Get("Content-Type"))
|
|
}
|
|
if gotHeaders.Get("Title") != "RESOLVED: db-1" {
|
|
t.Fatalf("expected resolved title, got %q", gotHeaders.Get("Title"))
|
|
}
|
|
if gotHeaders.Get("X-Static") != "keep-me" {
|
|
t.Fatalf("expected static header to be forwarded")
|
|
}
|
|
if gotHeaders.Get("X-Template") != "" {
|
|
t.Fatalf("expected templated header value to be skipped, got %q", gotHeaders.Get("X-Template"))
|
|
}
|
|
if !strings.Contains(gotBody, "Resolved: db-1 on node-1 is now healthy") {
|
|
t.Fatalf("unexpected body: %q", gotBody)
|
|
}
|
|
}
|
|
|
|
func TestSendResolvedWebhookNtfyMultipleAlertsHTTPError(t *testing.T) {
|
|
var gotMethod string
|
|
var gotTitle string
|
|
var gotBody string
|
|
|
|
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotMethod = r.Method
|
|
gotTitle = r.Header.Get("Title")
|
|
body, _ := io.ReadAll(r.Body)
|
|
gotBody = string(body)
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
_, _ = w.Write([]byte("temporarily unavailable"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
manager := NewNotificationManager("")
|
|
defer manager.Stop()
|
|
manager.webhookClient = server.Client()
|
|
if err := manager.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
|
|
t.Fatalf("allowlist: %v", err)
|
|
}
|
|
|
|
webhook := WebhookConfig{
|
|
Name: "resolved-ntfy",
|
|
URL: server.URL + "/ntfy",
|
|
Method: http.MethodPut,
|
|
Enabled: true,
|
|
Service: "ntfy",
|
|
}
|
|
|
|
alertList := []*alerts.Alert{
|
|
{ResourceName: "db-1", Node: "node-1"},
|
|
{ResourceName: "db-2", Node: "node-2"},
|
|
}
|
|
|
|
err := manager.sendResolvedWebhookNtfy(webhook, alertList, time.Date(2026, 2, 11, 12, 0, 0, 0, time.UTC))
|
|
if err == nil {
|
|
t.Fatalf("expected non-2xx response error")
|
|
}
|
|
if !strings.Contains(err.Error(), "ntfy webhook returned HTTP 503") {
|
|
t.Fatalf("expected HTTP status in error, got %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "temporarily unavailable") {
|
|
t.Fatalf("expected response body in error, got %v", err)
|
|
}
|
|
|
|
if gotMethod != http.MethodPut {
|
|
t.Fatalf("expected configured method PUT, got %q", gotMethod)
|
|
}
|
|
if gotTitle != "RESOLVED: 2 alerts" {
|
|
t.Fatalf("expected grouped resolved title, got %q", gotTitle)
|
|
}
|
|
if !strings.Contains(gotBody, "2 alerts resolved") {
|
|
t.Fatalf("expected grouped resolved body, got %q", gotBody)
|
|
}
|
|
}
|
|
|
|
func TestSendResolvedWebhookNtfyValidationAndRateLimit(t *testing.T) {
|
|
manager := NewNotificationManager("")
|
|
defer manager.Stop()
|
|
|
|
validAlert := []*alerts.Alert{{ResourceName: "vm-1", Node: "node-1"}}
|
|
|
|
err := manager.sendResolvedWebhookNtfy(
|
|
WebhookConfig{Name: "invalid-url", URL: "://bad-url", Enabled: true, Service: "ntfy"},
|
|
validAlert,
|
|
time.Now(),
|
|
)
|
|
if err == nil || !strings.Contains(err.Error(), "webhook URL validation failed") {
|
|
t.Fatalf("expected URL validation failure, got %v", err)
|
|
}
|
|
|
|
rateLimitedURL := "http://127.0.0.1:1/ntfy"
|
|
if err := manager.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
|
|
t.Fatalf("allowlist: %v", err)
|
|
}
|
|
manager.webhookRateLimits[rateLimitedURL] = &webhookRateLimit{
|
|
lastSent: time.Now(),
|
|
sentCount: WebhookRateLimitMax,
|
|
}
|
|
|
|
err = manager.sendResolvedWebhookNtfy(
|
|
WebhookConfig{Name: "rate-limited", URL: rateLimitedURL, Enabled: true, Service: "ntfy"},
|
|
validAlert,
|
|
time.Now(),
|
|
)
|
|
if err == nil || !strings.Contains(err.Error(), "rate limit exceeded") {
|
|
t.Fatalf("expected rate limit failure, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSendResolvedNotificationsDirectDispatchesEnabledTargets(t *testing.T) {
|
|
origSpawn := spawnAsync
|
|
spawnAsync = func(f func()) { f() }
|
|
t.Cleanup(func() { spawnAsync = origSpawn })
|
|
|
|
webhookHits := 0
|
|
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
webhookHits++
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
manager := NewNotificationManager("")
|
|
defer manager.Stop()
|
|
manager.webhookClient = server.Client()
|
|
if err := manager.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
|
|
t.Fatalf("allowlist: %v", err)
|
|
}
|
|
|
|
manager.emailManager = NewEnhancedEmailManager(EmailProviderConfig{
|
|
EmailConfig: EmailConfig{
|
|
From: "old@example.com",
|
|
To: []string{"old@example.com"},
|
|
SMTPHost: "invalid.localhost.test",
|
|
SMTPPort: 25,
|
|
},
|
|
MaxRetries: 0,
|
|
RetryDelay: 0,
|
|
RateLimit: 0,
|
|
})
|
|
|
|
appriseCalled := false
|
|
manager.appriseExec = func(ctx context.Context, path string, args []string) ([]byte, error) {
|
|
appriseCalled = true
|
|
return nil, nil
|
|
}
|
|
|
|
alertList := []*alerts.Alert{
|
|
{
|
|
ID: "a1",
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelWarning,
|
|
ResourceID: "r1",
|
|
ResourceName: "db-1",
|
|
Node: "node-1",
|
|
Message: "cpu warning",
|
|
Value: 87,
|
|
Threshold: 80,
|
|
StartTime: time.Now().Add(-2 * time.Minute),
|
|
},
|
|
}
|
|
|
|
manager.sendResolvedNotificationsDirect(
|
|
EmailConfig{
|
|
Enabled: true,
|
|
From: "new@example.com",
|
|
To: []string{"new@example.com"},
|
|
},
|
|
[]WebhookConfig{
|
|
{Name: "enabled", URL: server.URL + "/ok", Enabled: true, Service: "ntfy"},
|
|
{Name: "disabled", URL: server.URL + "/skip", Enabled: false, Service: "ntfy"},
|
|
},
|
|
AppriseConfig{
|
|
Enabled: true,
|
|
Mode: AppriseModeCLI,
|
|
Targets: []string{"discord://token"},
|
|
CLIPath: "apprise",
|
|
TimeoutSeconds: 1,
|
|
},
|
|
alertList,
|
|
time.Now(),
|
|
)
|
|
|
|
if webhookHits != 1 {
|
|
t.Fatalf("expected exactly one resolved webhook request, got %d", webhookHits)
|
|
}
|
|
if !appriseCalled {
|
|
t.Fatalf("expected resolved Apprise branch to be called")
|
|
}
|
|
if manager.emailManager.config.EmailConfig.From != "new@example.com" {
|
|
t.Fatalf("expected email manager config update from resolved-email send, got %q", manager.emailManager.config.EmailConfig.From)
|
|
}
|
|
}
|
|
|
|
func TestSendResolvedNotificationsDirectNoopForEmptyAlerts(t *testing.T) {
|
|
origSpawn := spawnAsync
|
|
spawned := 0
|
|
spawnAsync = func(f func()) { spawned++ }
|
|
t.Cleanup(func() { spawnAsync = origSpawn })
|
|
|
|
manager := NewNotificationManager("")
|
|
defer manager.Stop()
|
|
|
|
manager.sendResolvedNotificationsDirect(
|
|
EmailConfig{Enabled: true},
|
|
[]WebhookConfig{{Enabled: true}},
|
|
AppriseConfig{Enabled: true},
|
|
nil,
|
|
time.Now(),
|
|
)
|
|
|
|
if spawned != 0 {
|
|
t.Fatalf("expected no async dispatch for empty resolved alert batch, got %d", spawned)
|
|
}
|
|
}
|
|
|
|
func containsArg(args []string, value string) bool {
|
|
for _, arg := range args {
|
|
if strings.TrimSpace(arg) == value {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestNotificationManagerStop_Idempotent(t *testing.T) {
|
|
manager := NewNotificationManager("")
|
|
|
|
manager.Stop()
|
|
manager.Stop()
|
|
}
|