- Add support for testing Apprise notifications via /api/notifications/test endpoint - Users can now test their Apprise configuration (both CLI and HTTP modes) using method="apprise" - Added comprehensive unit tests for both CLI and HTTP modes - Tests verify correct behavior when Apprise is enabled/disabled - Tests validate that notifications are properly sent through Apprise channels Related to #584
624 lines
16 KiB
Go
624 lines
16 KiB
Go
package notifications
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
|
)
|
|
|
|
func flushPending(n *NotificationManager) {
|
|
n.mu.Lock()
|
|
if n.groupTimer != nil {
|
|
n.groupTimer.Stop()
|
|
n.groupTimer = nil
|
|
}
|
|
n.mu.Unlock()
|
|
n.sendGroupedAlerts()
|
|
}
|
|
|
|
func TestNormalizeAppriseConfig(t *testing.T) {
|
|
original := AppriseConfig{
|
|
Enabled: true,
|
|
Targets: []string{" discord://token ", "", "DISCORD://TOKEN"},
|
|
CLIPath: " ",
|
|
TimeoutSeconds: -5,
|
|
APIKeyHeader: "",
|
|
}
|
|
|
|
normalized := NormalizeAppriseConfig(original)
|
|
|
|
if normalized.Mode != AppriseModeCLI {
|
|
t.Fatalf("expected default mode cli, got %q", normalized.Mode)
|
|
}
|
|
|
|
if normalized.CLIPath != "apprise" {
|
|
t.Fatalf("expected default CLI path 'apprise', got %q", normalized.CLIPath)
|
|
}
|
|
|
|
if normalized.TimeoutSeconds != 15 {
|
|
t.Fatalf("expected timeout of 15 seconds, got %d", normalized.TimeoutSeconds)
|
|
}
|
|
|
|
if !normalized.Enabled {
|
|
t.Fatalf("expected config to remain enabled when targets exist")
|
|
}
|
|
|
|
if len(normalized.Targets) != 1 || normalized.Targets[0] != "discord://token" {
|
|
t.Fatalf("unexpected targets normalization result: %#v", normalized.Targets)
|
|
}
|
|
|
|
if normalized.APIKeyHeader != "X-API-KEY" {
|
|
t.Fatalf("expected default API key header, got %q", normalized.APIKeyHeader)
|
|
}
|
|
|
|
// When all targets removed, enabled should reset to false
|
|
empty := NormalizeAppriseConfig(AppriseConfig{Enabled: true})
|
|
if empty.Enabled {
|
|
t.Fatalf("expected enabled to be false when no targets configured")
|
|
}
|
|
|
|
httpConfig := NormalizeAppriseConfig(AppriseConfig{
|
|
Enabled: true,
|
|
Mode: AppriseModeHTTP,
|
|
ServerURL: "https://apprise.example.com/api/",
|
|
APIKey: " secret ",
|
|
APIKeyHeader: " X-Token ",
|
|
TimeoutSeconds: 200,
|
|
})
|
|
|
|
if httpConfig.Mode != AppriseModeHTTP {
|
|
t.Fatalf("expected HTTP mode, got %q", httpConfig.Mode)
|
|
}
|
|
if httpConfig.ServerURL != "https://apprise.example.com/api" {
|
|
t.Fatalf("expected server URL to be trimmed, got %q", httpConfig.ServerURL)
|
|
}
|
|
if httpConfig.APIKey != "secret" {
|
|
t.Fatalf("expected API key to be trimmed, got %q", httpConfig.APIKey)
|
|
}
|
|
if httpConfig.APIKeyHeader != "X-Token" {
|
|
t.Fatalf("expected API key header to be trimmed, got %q", httpConfig.APIKeyHeader)
|
|
}
|
|
if httpConfig.TimeoutSeconds != 120 {
|
|
t.Fatalf("expected timeout to clamp to 120, got %d", httpConfig.TimeoutSeconds)
|
|
}
|
|
if !httpConfig.Enabled {
|
|
t.Fatalf("expected HTTP config with server URL to remain enabled")
|
|
}
|
|
|
|
disabledHTTP := NormalizeAppriseConfig(AppriseConfig{
|
|
Enabled: true,
|
|
Mode: AppriseModeHTTP,
|
|
})
|
|
if disabledHTTP.Enabled {
|
|
t.Fatalf("expected HTTP config without server URL to disable notifications")
|
|
}
|
|
}
|
|
|
|
func TestSetCooldownClampsNegativeValues(t *testing.T) {
|
|
nm := NewNotificationManager("")
|
|
nm.SetCooldown(-10)
|
|
|
|
nm.mu.RLock()
|
|
if nm.cooldown != 0 {
|
|
nm.mu.RUnlock()
|
|
t.Fatalf("expected cooldown to clamp to zero, got %s", nm.cooldown)
|
|
}
|
|
nm.mu.RUnlock()
|
|
|
|
nm.SetCooldown(5)
|
|
nm.mu.RLock()
|
|
if nm.cooldown != 5*time.Minute {
|
|
nm.mu.RUnlock()
|
|
t.Fatalf("expected cooldown of five minutes, got %s", nm.cooldown)
|
|
}
|
|
nm.mu.RUnlock()
|
|
}
|
|
|
|
func TestSetGroupingWindowClampsNegativeValues(t *testing.T) {
|
|
nm := NewNotificationManager("")
|
|
nm.SetGroupingWindow(-60)
|
|
|
|
nm.mu.RLock()
|
|
if nm.groupWindow != 0 {
|
|
nm.mu.RUnlock()
|
|
t.Fatalf("expected grouping window to clamp to zero, got %s", nm.groupWindow)
|
|
}
|
|
nm.mu.RUnlock()
|
|
|
|
nm.SetGroupingWindow(120)
|
|
nm.mu.RLock()
|
|
if nm.groupWindow != 120*time.Second {
|
|
nm.mu.RUnlock()
|
|
t.Fatalf("expected grouping window of 120 seconds, got %s", nm.groupWindow)
|
|
}
|
|
nm.mu.RUnlock()
|
|
}
|
|
|
|
func TestSendGroupedAppriseInvokesExecutor(t *testing.T) {
|
|
nm := NewNotificationManager("")
|
|
nm.SetGroupingWindow(0)
|
|
nm.SetEmailConfig(EmailConfig{Enabled: false})
|
|
|
|
done := make(chan struct{})
|
|
var capturedArgs []string
|
|
|
|
nm.appriseExec = func(ctx context.Context, path string, args []string) ([]byte, error) {
|
|
if path != "apprise" {
|
|
t.Fatalf("expected CLI path 'apprise', got %q", path)
|
|
}
|
|
capturedArgs = append([]string(nil), args...)
|
|
close(done)
|
|
return []byte("success"), nil
|
|
}
|
|
|
|
nm.SetAppriseConfig(AppriseConfig{
|
|
Enabled: true,
|
|
Targets: []string{"discord://token"},
|
|
TimeoutSeconds: 10,
|
|
})
|
|
|
|
alert := &alerts.Alert{
|
|
ID: "test",
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelCritical,
|
|
ResourceID: "vm-100",
|
|
ResourceName: "vm-100",
|
|
Message: "CPU usage high",
|
|
Value: 95,
|
|
Threshold: 90,
|
|
StartTime: time.Now().Add(-time.Minute),
|
|
LastSeen: time.Now(),
|
|
}
|
|
|
|
nm.mu.Lock()
|
|
nm.pendingAlerts = append(nm.pendingAlerts, alert)
|
|
nm.mu.Unlock()
|
|
|
|
nm.sendGroupedAlerts()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("timed out waiting for Apprise executor to run")
|
|
}
|
|
|
|
if len(capturedArgs) == 0 {
|
|
t.Fatalf("expected Apprise executor to receive arguments")
|
|
}
|
|
|
|
if capturedArgs[len(capturedArgs)-1] != "discord://token" {
|
|
t.Fatalf("expected target URL as last argument, got %v", capturedArgs)
|
|
}
|
|
}
|
|
|
|
func TestSendGroupedAppriseHTTP(t *testing.T) {
|
|
nm := NewNotificationManager("https://pulse.local")
|
|
nm.SetGroupingWindow(0)
|
|
nm.SetEmailConfig(EmailConfig{Enabled: false})
|
|
|
|
type apprisePayload struct {
|
|
Body string `json:"body"`
|
|
Title string `json:"title"`
|
|
Type string `json:"type"`
|
|
URLs []string `json:"urls"`
|
|
}
|
|
|
|
type capturedRequest struct {
|
|
Method string
|
|
Path string
|
|
ContentType string
|
|
APIKey string
|
|
Payload apprisePayload
|
|
}
|
|
|
|
requests := make(chan capturedRequest, 1)
|
|
errs := make(chan error, 1)
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer r.Body.Close()
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
errs <- err
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var payload apprisePayload
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
errs <- err
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
requests <- capturedRequest{
|
|
Method: r.Method,
|
|
Path: r.URL.Path,
|
|
ContentType: r.Header.Get("Content-Type"),
|
|
APIKey: r.Header.Get("X-Test-Key"),
|
|
Payload: payload,
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
nm.SetAppriseConfig(AppriseConfig{
|
|
Enabled: true,
|
|
Mode: AppriseModeHTTP,
|
|
ServerURL: server.URL,
|
|
ConfigKey: "primary",
|
|
APIKey: "secret",
|
|
APIKeyHeader: "X-Test-Key",
|
|
Targets: []string{"discord://token"},
|
|
TimeoutSeconds: 10,
|
|
})
|
|
|
|
alert := &alerts.Alert{
|
|
ID: "test",
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelCritical,
|
|
ResourceID: "vm-100",
|
|
ResourceName: "vm-100",
|
|
Message: "CPU usage high",
|
|
Value: 95,
|
|
Threshold: 90,
|
|
StartTime: time.Now().Add(-time.Minute),
|
|
LastSeen: time.Now(),
|
|
}
|
|
|
|
nm.mu.Lock()
|
|
nm.pendingAlerts = append(nm.pendingAlerts, alert)
|
|
nm.mu.Unlock()
|
|
|
|
nm.sendGroupedAlerts()
|
|
|
|
var req capturedRequest
|
|
select {
|
|
case req = <-requests:
|
|
case err := <-errs:
|
|
t.Fatalf("server error: %v", err)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("timed out waiting for Apprise API request")
|
|
}
|
|
|
|
if req.Method != http.MethodPost {
|
|
t.Fatalf("expected POST request, got %s", req.Method)
|
|
}
|
|
if req.Path != "/notify/primary" {
|
|
t.Fatalf("expected notify path with config key, got %s", req.Path)
|
|
}
|
|
if req.ContentType != "application/json" {
|
|
t.Fatalf("expected JSON content type, got %s", req.ContentType)
|
|
}
|
|
if req.APIKey != "secret" {
|
|
t.Fatalf("expected API key header to be set, got %q", req.APIKey)
|
|
}
|
|
if req.Payload.Title != "Pulse alert: vm-100" {
|
|
t.Fatalf("unexpected title: %s", req.Payload.Title)
|
|
}
|
|
if req.Payload.Type != "failure" {
|
|
t.Fatalf("expected failure notification type, got %s", req.Payload.Type)
|
|
}
|
|
if len(req.Payload.URLs) != 1 || req.Payload.URLs[0] != "discord://token" {
|
|
t.Fatalf("unexpected URLs in payload: %#v", req.Payload.URLs)
|
|
}
|
|
if !strings.Contains(req.Payload.Body, "CPU usage high") {
|
|
t.Fatalf("expected alert message in payload body, got %s", req.Payload.Body)
|
|
}
|
|
if !strings.Contains(req.Payload.Body, "Dashboard: https://pulse.local") {
|
|
t.Fatalf("expected dashboard link in payload body, got %s", req.Payload.Body)
|
|
}
|
|
}
|
|
|
|
func TestNotificationCooldownAllowsNewAlertInstance(t *testing.T) {
|
|
nm := NewNotificationManager("")
|
|
nm.SetCooldown(1) // 1 minute cooldown
|
|
nm.SetGroupingWindow(3600) // keep timer from firing immediately
|
|
|
|
alertStart := time.Now().Add(-time.Minute)
|
|
alertA := &alerts.Alert{
|
|
ID: "vm-100-memory",
|
|
Type: "memory",
|
|
Level: alerts.AlertLevelWarning,
|
|
StartTime: alertStart,
|
|
}
|
|
|
|
nm.SendAlert(alertA)
|
|
flushPending(nm)
|
|
|
|
nm.mu.RLock()
|
|
firstRecord, ok := nm.lastNotified[alertA.ID]
|
|
nm.mu.RUnlock()
|
|
if !ok {
|
|
t.Fatalf("first notification not recorded")
|
|
}
|
|
|
|
nm.SendAlert(alertA)
|
|
|
|
nm.mu.RLock()
|
|
pendingAfter := len(nm.pendingAlerts)
|
|
nm.mu.RUnlock()
|
|
if pendingAfter != 0 {
|
|
t.Fatalf("cooldown alert should not be queued, found %d pending", pendingAfter)
|
|
}
|
|
|
|
alertRestart := &alerts.Alert{
|
|
ID: "vm-100-memory",
|
|
Type: "memory",
|
|
Level: alerts.AlertLevelWarning,
|
|
StartTime: alertStart.Add(time.Minute),
|
|
}
|
|
|
|
nm.SendAlert(alertRestart)
|
|
flushPending(nm)
|
|
|
|
nm.mu.RLock()
|
|
recordAfter := nm.lastNotified[alertRestart.ID]
|
|
nm.mu.RUnlock()
|
|
|
|
if !recordAfter.alertStart.Equal(alertRestart.StartTime) {
|
|
t.Fatalf("expected alertStart %v, got %v", alertRestart.StartTime, recordAfter.alertStart)
|
|
}
|
|
if !recordAfter.lastSent.After(firstRecord.lastSent) {
|
|
t.Fatalf("lastSent was not updated for new alert instance")
|
|
}
|
|
}
|
|
|
|
func TestCancelAlertRemovesPending(t *testing.T) {
|
|
nm := NewNotificationManager("")
|
|
nm.SetGroupingWindow(120)
|
|
|
|
alertA := &alerts.Alert{
|
|
ID: "vm-100-disk",
|
|
Type: "disk",
|
|
Level: alerts.AlertLevelWarning,
|
|
StartTime: time.Now(),
|
|
}
|
|
alertB := &alerts.Alert{
|
|
ID: "vm-101-disk",
|
|
Type: "disk",
|
|
Level: alerts.AlertLevelWarning,
|
|
StartTime: time.Now(),
|
|
}
|
|
|
|
nm.SendAlert(alertA)
|
|
nm.SendAlert(alertB)
|
|
|
|
nm.CancelAlert(alertA.ID)
|
|
|
|
nm.mu.RLock()
|
|
remaining := make([]string, 0, len(nm.pendingAlerts))
|
|
for _, pending := range nm.pendingAlerts {
|
|
if pending != nil {
|
|
remaining = append(remaining, pending.ID)
|
|
}
|
|
}
|
|
groupTimerActive := nm.groupTimer != nil
|
|
nm.mu.RUnlock()
|
|
|
|
if len(remaining) != 1 || remaining[0] != alertB.ID {
|
|
t.Fatalf("expected only %s to remain pending, got %v", alertB.ID, remaining)
|
|
}
|
|
if !groupTimerActive {
|
|
t.Fatalf("expected grouping timer to remain active while other alerts pending")
|
|
}
|
|
|
|
nm.CancelAlert(alertB.ID)
|
|
|
|
nm.mu.RLock()
|
|
if len(nm.pendingAlerts) != 0 {
|
|
nm.mu.RUnlock()
|
|
t.Fatalf("expected no pending alerts after cancelling all, found %d", len(nm.pendingAlerts))
|
|
}
|
|
timerStopped := nm.groupTimer == nil
|
|
nm.mu.RUnlock()
|
|
|
|
if !timerStopped {
|
|
t.Fatalf("expected grouping timer to be cleared when no alerts remain")
|
|
}
|
|
}
|
|
|
|
func TestConvertWebhookCustomFields(t *testing.T) {
|
|
if result := convertWebhookCustomFields(nil); result != nil {
|
|
t.Fatalf("expected nil for empty input, got %#v", result)
|
|
}
|
|
|
|
original := map[string]string{
|
|
"app_token": "abc123",
|
|
"user_token": "user456",
|
|
}
|
|
|
|
converted := convertWebhookCustomFields(original)
|
|
if len(converted) != len(original) {
|
|
t.Fatalf("expected %d keys, got %d", len(original), len(converted))
|
|
}
|
|
|
|
for key, value := range original {
|
|
if got, ok := converted[key]; !ok || got != value {
|
|
t.Fatalf("expected %s=%s, got %v (present=%v)", key, value, got, ok)
|
|
}
|
|
}
|
|
|
|
// Mutate original map and ensure converted copy remains unchanged
|
|
original["extra"] = "new-value"
|
|
if _, ok := converted["extra"]; ok {
|
|
t.Fatalf("expected converted map to be independent of original mutations")
|
|
}
|
|
}
|
|
|
|
func TestRenderWebhookURL_PathEncoding(t *testing.T) {
|
|
data := WebhookPayloadData{
|
|
Message: "CPU spike detected",
|
|
}
|
|
|
|
result, err := renderWebhookURL("https://example.com/alerts/{{.Message}}", data)
|
|
if err != nil {
|
|
t.Fatalf("expected no error rendering URL template, got %v", err)
|
|
}
|
|
|
|
expected := "https://example.com/alerts/CPU%20spike%20detected"
|
|
if result != expected {
|
|
t.Fatalf("expected %s, got %s", expected, result)
|
|
}
|
|
}
|
|
|
|
func TestRenderWebhookURL_QueryEncoding(t *testing.T) {
|
|
data := WebhookPayloadData{
|
|
Message: "CPU & Memory > 90%",
|
|
}
|
|
|
|
result, err := renderWebhookURL("https://hooks.example.com?msg={{urlquery .Message}}", data)
|
|
if err != nil {
|
|
t.Fatalf("expected no error rendering URL template, got %v", err)
|
|
}
|
|
|
|
expected := "https://hooks.example.com?msg=CPU+%26+Memory+%3E+90%25"
|
|
if result != expected {
|
|
t.Fatalf("expected %s, got %s", expected, result)
|
|
}
|
|
}
|
|
|
|
func TestRenderWebhookURL_InvalidTemplate(t *testing.T) {
|
|
_, err := renderWebhookURL("https://example.com/{{.Missing", WebhookPayloadData{})
|
|
if err == nil {
|
|
t.Fatalf("expected error for invalid URL template, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSendTestNotificationApprise(t *testing.T) {
|
|
nm := NewNotificationManager("")
|
|
nm.SetEmailConfig(EmailConfig{Enabled: false})
|
|
|
|
// Test 1: Apprise not enabled should return error
|
|
nm.SetAppriseConfig(AppriseConfig{
|
|
Enabled: false,
|
|
Targets: []string{"discord://token"},
|
|
})
|
|
|
|
err := nm.SendTestNotification("apprise")
|
|
if err == nil {
|
|
t.Fatalf("expected error when Apprise is disabled, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "not enabled") {
|
|
t.Fatalf("expected 'not enabled' error, got: %v", err)
|
|
}
|
|
|
|
// Test 2: Apprise enabled with CLI mode should invoke executor
|
|
done := make(chan struct{})
|
|
var capturedArgs []string
|
|
|
|
nm.appriseExec = func(ctx context.Context, path string, args []string) ([]byte, error) {
|
|
if path != "apprise" {
|
|
t.Fatalf("expected CLI path 'apprise', got %q", path)
|
|
}
|
|
capturedArgs = append([]string(nil), args...)
|
|
close(done)
|
|
return []byte("success"), nil
|
|
}
|
|
|
|
nm.SetAppriseConfig(AppriseConfig{
|
|
Enabled: true,
|
|
Targets: []string{"discord://token"},
|
|
TimeoutSeconds: 10,
|
|
})
|
|
|
|
err = nm.SendTestNotification("apprise")
|
|
if err != nil {
|
|
t.Fatalf("expected no error when testing Apprise, got: %v", err)
|
|
}
|
|
|
|
// Wait for the executor to be called
|
|
select {
|
|
case <-done:
|
|
// Success - executor was called
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("timeout waiting for Apprise executor to be called")
|
|
}
|
|
|
|
// Verify the arguments contain the target
|
|
foundTarget := false
|
|
for _, arg := range capturedArgs {
|
|
if arg == "discord://token" {
|
|
foundTarget = true
|
|
break
|
|
}
|
|
}
|
|
if !foundTarget {
|
|
t.Fatalf("expected target 'discord://token' in args, got: %v", capturedArgs)
|
|
}
|
|
}
|
|
|
|
func TestSendTestNotificationAppriseHTTP(t *testing.T) {
|
|
nm := NewNotificationManager("")
|
|
nm.SetEmailConfig(EmailConfig{Enabled: false})
|
|
|
|
type apprisePayload struct {
|
|
Body string `json:"body"`
|
|
Title string `json:"title"`
|
|
Type string `json:"type"`
|
|
URLs []string `json:"urls"`
|
|
}
|
|
|
|
requests := make(chan apprisePayload, 1)
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer r.Body.Close()
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var payload apprisePayload
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
requests <- payload
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"ok": true}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
nm.SetAppriseConfig(AppriseConfig{
|
|
Enabled: true,
|
|
Mode: AppriseModeHTTP,
|
|
ServerURL: server.URL,
|
|
ConfigKey: "test-key",
|
|
TimeoutSeconds: 10,
|
|
})
|
|
|
|
err := nm.SendTestNotification("apprise")
|
|
if err != nil {
|
|
t.Fatalf("expected no error when testing Apprise HTTP, got: %v", err)
|
|
}
|
|
|
|
// Wait for the HTTP request
|
|
select {
|
|
case payload := <-requests:
|
|
// Verify the payload contains test alert information
|
|
if payload.Title == "" {
|
|
t.Fatalf("expected non-empty title in Apprise payload")
|
|
}
|
|
if payload.Body == "" {
|
|
t.Fatalf("expected non-empty body in Apprise payload")
|
|
}
|
|
if !strings.Contains(payload.Body, "test alert") && !strings.Contains(payload.Body, "Test Resource") {
|
|
t.Fatalf("expected test alert content in body, got: %s", payload.Body)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("timeout waiting for Apprise HTTP request")
|
|
}
|
|
}
|