Files
pulse/internal/api/alerting/notifications_test.go
T
rcourtman f279dc0b07 Resolve masked webhook values per key and keep responses masked
The webhook editor sends back the ***REDACTED*** values the list returns
for every header, custom field and signing secret the user did not
retype. Three paths mishandled them:

- Update restored the whole saved header or custom-field map whenever any
  value was masked, silently dropping a header or field added in the same
  edit.
- Update echoed the restored saved custom fields, so a Pushover token and
  user key came back in the response even though the list masks them, in
  breach of the api-contracts webhook response clause. Create echoed the
  request, and could store a masked value verbatim.
- The editor's Test button posts the form, so testing an edit after a
  reload sent the literal mask as the token or header.

Resolve each masked value to the saved value of the same key on update and
on a form test that carries a saved webhook's id, keep values typed in the
same request, and drop masks with no saved value. Build list, create and
update responses from one masked shape. Normalization now drops blank
headers, so clearing a header removes it instead of sending an empty
Content-Type that generic receivers cannot parse.
2026-10-06 09:21:41 +01:00

1556 lines
60 KiB
Go

package alerting
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// Configuration responses intentionally let an authorised editor round-trip
// targets; routine logs must not duplicate their credentials or config keys.
func TestAppriseConfigurationLogsWithholdSecrets(t *testing.T) {
const secret = "apprise-api-synthetic-secret"
var captured bytes.Buffer
original := log.Logger
log.Logger = zerolog.New(zerolog.SyncWriter(&captured))
defer func() { log.Logger = original }()
cfg := notifications.AppriseConfig{Enabled: true, Mode: notifications.AppriseModeHTTP, ServerURL: "https://fixture/" + secret,
ConfigKey: secret, APIKey: secret, CLIPath: secret, APIKeyHeader: secret, Targets: []string{"future://" + secret}}
manager := new(MockNotificationManager)
persistence := new(MockNotificationConfigPersistence)
monitor := new(MockNotificationMonitor)
manager.On("GetAppriseConfig").Return(cfg).Twice()
manager.On("SetAppriseConfig", mock.MatchedBy(func(got notifications.AppriseConfig) bool {
return got.ServerURL == cfg.ServerURL && got.ConfigKey == secret && got.APIKey == secret && got.Targets[0] == cfg.Targets[0]
})).Return().Once()
persistence.On("SaveAppriseConfig", mock.MatchedBy(func(got notifications.AppriseConfig) bool {
return got.ServerURL == cfg.ServerURL && got.ConfigKey == secret && got.APIKey == secret && got.Targets[0] == cfg.Targets[0]
})).Return(nil).Once()
monitor.On("GetNotificationManager").Return(manager)
monitor.On("GetConfigPersistence").Return(persistence)
body, _ := json.Marshal(cfg)
w := httptest.NewRecorder()
NewNotificationHandlers(nil, monitor).UpdateAppriseConfig(w, httptest.NewRequest(http.MethodPost, "/api/notifications/apprise", bytes.NewReader(body)))
if w.Code != http.StatusOK || strings.Contains(captured.String(), secret) {
t.Fatal("configuration update failed or a synthetic credential reached its log")
}
if !strings.Contains(captured.String(), "hasConfigKey") || !strings.Contains(captured.String(), "targetCount") {
t.Fatal("structured configuration diagnostics were lost")
}
var response appriseConfigResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.APIKey != "" || !response.HasAPIKey {
t.Fatal("saved API-key response contract changed")
}
if response.ServerURL != cfg.ServerURL || response.ConfigKey != cfg.ConfigKey || len(response.Targets) != 1 || response.Targets[0] != cfg.Targets[0] {
t.Fatal("authorised configuration round-trip changed")
}
manager.AssertExpectations(t)
persistence.AssertExpectations(t)
}
// Exercise the real sender through the handler, not an assumed safe mock error.
func TestAppriseTestResponseWithholdsProviderSecrets(t *testing.T) {
const secret = "apprise-api-synthetic-secret"
var captured bytes.Buffer
original := log.Logger
log.Logger = zerolog.New(zerolog.SyncWriter(&captured))
defer func() { log.Logger = original }()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, "provider-private-content ", secret, " ", r.URL.String())
}))
defer server.Close()
manager := notifications.NewNotificationManagerWithDataDir("", t.TempDir())
defer manager.Stop()
if err := manager.UpdateAllowedPrivateCIDRs("127.0.0.1/32,::1/128"); err != nil {
t.Fatal(err)
}
monitor := new(MockNotificationMonitor)
monitor.On("GetNotificationManager").Return(manager)
body, _ := json.Marshal(map[string]any{"method": "apprise", "config": notifications.AppriseConfig{Enabled: true, Mode: notifications.AppriseModeHTTP,
ServerURL: server.URL + "/" + secret, ConfigKey: secret, APIKey: secret}})
w := httptest.NewRecorder()
NewNotificationHandlers(nil, monitor).TestNotification(w, httptest.NewRequest(http.MethodPost, "/api/notifications/test", bytes.NewReader(body)))
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "HTTP 401") {
t.Fatal("structured HTTP failure was lost from the test result")
}
for _, forbidden := range []string{secret, "provider-private-content"} {
if strings.Contains(w.Body.String(), forbidden) || strings.Contains(captured.String(), forbidden) {
t.Error("test response or log exposed synthetic provider credentials/content")
}
}
}
func TestRedactSecretsFromURL(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
// No secrets - should pass through unchanged
{
name: "no secrets in URL",
input: "https://example.com/webhook",
expected: "https://example.com/webhook",
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "URL with unrelated query params",
input: "https://example.com/api?foo=bar&baz=qux",
expected: "https://example.com/api?foo=bar&baz=qux",
},
// Telegram bot token patterns
{
name: "telegram bot token with sendMessage",
input: "https://api.telegram.org/bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/sendMessage",
expected: "https://api.telegram.org/botREDACTED/sendMessage",
},
{
name: "telegram bot token no trailing path",
input: "https://api.telegram.org/bot123456:ABC-token",
expected: "https://api.telegram.org/botREDACTED",
},
{
name: "telegram bot token with query string",
input: "https://api.telegram.org/bot123456:ABC-token?chat_id=123",
expected: "https://api.telegram.org/botREDACTED?chat_id=123",
},
{
name: "telegram bot token with path and query",
input: "https://api.telegram.org/bot123456:token/sendMessage?chat_id=123",
expected: "https://api.telegram.org/botREDACTED/sendMessage?chat_id=123",
},
// Query parameter secrets
{
name: "token query param",
input: "https://example.com/webhook?token=secret123",
expected: "https://example.com/webhook?token=REDACTED",
},
{
name: "apikey query param",
input: "https://example.com/api?apikey=xyz123",
expected: "https://example.com/api?apikey=REDACTED",
},
{
name: "api_key query param with underscore",
input: "https://example.com/api?api_key=xyz123",
expected: "https://example.com/api?api_key=REDACTED",
},
{
name: "key query param",
input: "https://example.com/api?key=mykey123",
expected: "https://example.com/api?key=REDACTED",
},
{
name: "secret query param",
input: "https://example.com/api?secret=mysecret",
expected: "https://example.com/api?secret=REDACTED",
},
{
name: "password query param",
input: "https://example.com/api?password=pass123",
expected: "https://example.com/api?password=REDACTED",
},
// Multiple parameters
{
name: "secret param with other params before",
input: "https://example.com/api?foo=bar&token=secret",
expected: "https://example.com/api?foo=bar&token=REDACTED",
},
{
name: "secret param with other params after",
input: "https://example.com/api?token=secret&foo=bar",
expected: "https://example.com/api?token=REDACTED&foo=bar",
},
{
name: "multiple different secret params",
input: "https://example.com/api?token=tok&apikey=key",
expected: "https://example.com/api?token=REDACTED&apikey=REDACTED",
},
// Edge cases
{
name: "secret param with fragment",
input: "https://example.com/api?token=secret#section",
expected: "https://example.com/api?token=REDACTED#section",
},
{
name: "bot in path but not telegram pattern",
input: "https://example.com/robots.txt",
expected: "https://example.com/robots.txt",
},
{
name: "combined telegram and query param secrets",
input: "https://api.telegram.org/bot123:token/send?token=abc",
expected: "https://api.telegram.org/botREDACTED/send?token=REDACTED",
},
// Boundary checking - prefixed params should NOT be redacted
{
name: "prefixed param name should not match",
input: "https://example.com/api?extra_token=abc&myapikey=xyz",
expected: "https://example.com/api?extra_token=abc&myapikey=xyz",
},
{
name: "prefixed param with real sensitive param",
input: "https://example.com/api?extra_token=abc&token=secret",
expected: "https://example.com/api?extra_token=abc&token=REDACTED",
},
{
name: "multiple prefixed params unchanged",
input: "https://example.com/api?mytoken=a&yourkey=b&thesecret=c",
expected: "https://example.com/api?mytoken=a&yourkey=b&thesecret=c",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := redactSecretsFromURL(tt.input)
if result != tt.expected {
t.Errorf("redactSecretsFromURL(%q)\ngot: %q\nwant: %q", tt.input, result, tt.expected)
}
})
}
}
type MockNotificationMonitor struct {
mock.Mock
}
func (m *MockNotificationMonitor) GetNotificationManager() NotificationManager {
args := m.Called()
return args.Get(0).(NotificationManager)
}
func (m *MockNotificationMonitor) GetConfigPersistence() NotificationConfigPersistence {
args := m.Called()
return args.Get(0).(NotificationConfigPersistence)
}
type MockNotificationManager struct {
mock.Mock
}
func (m *MockNotificationManager) GetEmailConfig() notifications.EmailConfig {
args := m.Called()
return args.Get(0).(notifications.EmailConfig)
}
func (m *MockNotificationManager) SetEmailConfig(cfg notifications.EmailConfig) {
m.Called(cfg)
}
func (m *MockNotificationManager) GetAppriseConfig() notifications.AppriseConfig {
args := m.Called()
return args.Get(0).(notifications.AppriseConfig)
}
func (m *MockNotificationManager) SetAppriseConfig(cfg notifications.AppriseConfig) {
m.Called(cfg)
}
func (m *MockNotificationManager) GetWebhooks() []notifications.WebhookConfig {
args := m.Called()
return args.Get(0).([]notifications.WebhookConfig)
}
func (m *MockNotificationManager) ValidateWebhookURL(url string) error {
args := m.Called(url)
return args.Error(0)
}
func (m *MockNotificationManager) AddWebhook(w notifications.WebhookConfig) {
m.Called(w)
}
func (m *MockNotificationManager) UpdateWebhook(id string, w notifications.WebhookConfig) error {
args := m.Called(id, w)
return args.Error(0)
}
func (m *MockNotificationManager) DeleteWebhook(id string) error {
args := m.Called(id)
return args.Error(0)
}
func (m *MockNotificationManager) SendTestWebhook(w notifications.WebhookConfig) error {
args := m.Called(w)
return args.Error(0)
}
func (m *MockNotificationManager) SendTestNotificationWithConfig(method string, cfg *notifications.EmailConfig, nodeInfo *notifications.TestNodeInfo) error {
args := m.Called(method, cfg, nodeInfo)
return args.Error(0)
}
func (m *MockNotificationManager) SendTestAppriseWithConfig(cfg notifications.AppriseConfig) error {
args := m.Called(cfg)
return args.Error(0)
}
func (m *MockNotificationManager) SendTestNotification(method string) error {
args := m.Called(method)
return args.Error(0)
}
func (m *MockNotificationManager) GetWebhookHistory() []notifications.WebhookDelivery {
args := m.Called()
return args.Get(0).([]notifications.WebhookDelivery)
}
func (m *MockNotificationManager) TestEnhancedWebhook(w notifications.EnhancedWebhookConfig) (int, string, error) {
args := m.Called(w)
return args.Int(0), args.String(1), args.Error(2)
}
func (m *MockNotificationManager) GetQueueStats() (map[string]int, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(map[string]int), args.Error(1)
}
func (m *MockNotificationManager) GetTelemetryStats(since time.Time) (notifications.TelemetryStats, error) {
args := m.Called(since)
return args.Get(0).(notifications.TelemetryStats), args.Error(1)
}
func (m *MockNotificationManager) GetDeliveryLog(since time.Time, limit int) ([]notifications.DeliveryLogEntry, error) {
args := m.Called(since, limit)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]notifications.DeliveryLogEntry), args.Error(1)
}
func (m *MockNotificationManager) IsEnabled() bool {
args := m.Called()
return args.Bool(0)
}
type MockNotificationConfigPersistence struct {
mock.Mock
}
func (m *MockNotificationConfigPersistence) SaveEmailConfig(cfg notifications.EmailConfig) error {
args := m.Called(cfg)
return args.Error(0)
}
func (m *MockNotificationConfigPersistence) SaveAppriseConfig(cfg notifications.AppriseConfig) error {
args := m.Called(cfg)
return args.Error(0)
}
func (m *MockNotificationConfigPersistence) SaveWebhooks(w []notifications.WebhookConfig) error {
args := m.Called(w)
return args.Error(0)
}
func (m *MockNotificationConfigPersistence) IsEncryptionEnabled() bool {
args := m.Called()
return args.Bool(0)
}
func TestNotificationHandlers(t *testing.T) {
mockMonitor := new(MockNotificationMonitor)
mockManager := new(MockNotificationManager)
mockPersistence := new(MockNotificationConfigPersistence)
mockMonitor.On("GetNotificationManager").Return(mockManager)
mockMonitor.On("GetConfigPersistence").Return(mockPersistence)
h := NewNotificationHandlers(nil, mockMonitor)
t.Run("SetMonitor", func(t *testing.T) {
h.SetMonitor(mockMonitor)
// Should not panic and should replace the monitor
})
t.Run("GetEmailConfig", func(t *testing.T) {
cfg := notifications.EmailConfig{
Enabled: true,
SMTPHost: "smtp.example.com",
Password: "password123",
}
mockManager.On("GetEmailConfig").Return(cfg).Once()
req := httptest.NewRequest("GET", "/api/notifications/email", nil)
w := httptest.NewRecorder()
h.GetEmailConfig(w, req)
assert.Equal(t, 200, w.Code)
var resp notifications.EmailConfig
_ = json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, "smtp.example.com", resp.SMTPHost)
assert.Empty(t, resp.Password) // Should be redacted
})
t.Run("UpdateEmailConfig", func(t *testing.T) {
cfg := notifications.EmailConfig{
Enabled: true,
SMTPHost: "smtp.example.com",
Password: "newpassword",
}
mockManager.On("GetEmailConfig").Return(notifications.EmailConfig{}).Once()
mockManager.On("SetEmailConfig", mock.Anything).Return().Once()
mockPersistence.On("SaveEmailConfig", mock.Anything).Return(nil).Once()
body, _ := json.Marshal(cfg)
req := httptest.NewRequest("PUT", "/api/notifications/email", bytes.NewReader(body))
w := httptest.NewRecorder()
h.UpdateEmailConfig(w, req)
assert.Equal(t, 200, w.Code)
mockManager.AssertExpectations(t)
mockPersistence.AssertExpectations(t)
})
t.Run("UpdateEmailConfig_PreservesOmittedTagRouting", func(t *testing.T) {
existing := notifications.EmailConfig{
TagFilter: []string{"customer:alpha", "critical"},
TagMode: "any",
MinimumSeverity: "critical",
}
mockManager.On("GetEmailConfig").Return(existing).Once()
mockManager.On("SetEmailConfig", mock.MatchedBy(func(cfg notifications.EmailConfig) bool {
return assert.ObjectsAreEqual(
[]string{"customer:alpha", "critical"},
cfg.TagFilter,
) && cfg.TagMode == "any" && cfg.MinimumSeverity == "critical"
})).Return().Once()
mockPersistence.On("SaveEmailConfig", mock.MatchedBy(func(cfg notifications.EmailConfig) bool {
return assert.ObjectsAreEqual(
[]string{"customer:alpha", "critical"},
cfg.TagFilter,
) && cfg.TagMode == "any" && cfg.MinimumSeverity == "critical"
})).Return(nil).Once()
req := httptest.NewRequest(
"PUT",
"/api/notifications/email",
bytes.NewBufferString(`{"enabled":true}`),
)
w := httptest.NewRecorder()
h.UpdateEmailConfig(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
t.Run("UpdateEmailConfig_ClearsTagRoutingExplicitly", func(t *testing.T) {
existing := notifications.EmailConfig{
TagFilter: []string{"customer:alpha"},
TagMode: "any",
}
mockManager.On("GetEmailConfig").Return(existing).Once()
mockManager.On("SetEmailConfig", mock.MatchedBy(func(cfg notifications.EmailConfig) bool {
return len(cfg.TagFilter) == 0 && cfg.TagMode == "all"
})).Return().Once()
mockPersistence.On("SaveEmailConfig", mock.MatchedBy(func(cfg notifications.EmailConfig) bool {
return len(cfg.TagFilter) == 0 && cfg.TagMode == "all"
})).Return(nil).Once()
req := httptest.NewRequest(
"PUT",
"/api/notifications/email",
bytes.NewBufferString(`{"enabled":true,"tagFilter":[],"tagFilterMode":"all"}`),
)
w := httptest.NewRecorder()
h.UpdateEmailConfig(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
t.Run("UpdateEmailConfig_InvalidJSON", func(t *testing.T) {
req := httptest.NewRequest("PUT", "/api/notifications/email", bytes.NewReader([]byte("{invalid}")))
w := httptest.NewRecorder()
h.UpdateEmailConfig(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("GetWebhooks", func(t *testing.T) {
webhooks := []notifications.WebhookConfig{
{
ID: "wh1",
Name: "Test Webhook",
URL: "https://example.com",
Headers: map[string]string{"Authorization": "Bearer token"},
Mention: "@everyone",
TagFilter: []string{"customer:alpha", "critical"},
TagMode: "any",
MinimumSeverity: "critical",
},
}
mockManager.On("GetWebhooks").Return(webhooks).Once()
req := httptest.NewRequest("GET", "/api/notifications/webhooks", nil)
w := httptest.NewRecorder()
h.GetWebhooks(w, req)
assert.Equal(t, 200, w.Code)
var resp []map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, 1, len(resp))
assert.Equal(t, "wh1", resp[0]["id"])
assert.Equal(t, "@everyone", resp[0]["mention"])
assert.Equal(t, []interface{}{"customer:alpha", "critical"}, resp[0]["tagFilter"])
assert.Equal(t, "any", resp[0]["tagFilterMode"])
assert.Equal(t, "critical", resp[0]["minimumSeverity"])
headers := resp[0]["headers"].(map[string]interface{})
assert.Equal(t, "***REDACTED***", headers["Authorization"])
})
t.Run("CreateWebhook", func(t *testing.T) {
webhook := notifications.WebhookConfig{
Name: "New Webhook",
URL: "https://example.com/new",
}
mockManager.On("ValidateWebhookURL", "https://example.com/new").Return(nil).Once()
mockManager.On("AddWebhook", mock.Anything).Return().Once()
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{}).Once()
mockPersistence.On("SaveWebhooks", mock.Anything).Return(nil).Once()
body, _ := json.Marshal(webhook)
req := httptest.NewRequest("POST", "/api/notifications/webhooks", bytes.NewReader(body))
w := httptest.NewRecorder()
h.CreateWebhook(w, req)
assert.Equal(t, 200, w.Code)
})
t.Run("CreateWebhook_ValidationError", func(t *testing.T) {
webhook := notifications.WebhookConfig{URL: "invalid"}
mockManager.On("ValidateWebhookURL", "invalid").Return(fmt.Errorf("invalid url")).Once()
body, _ := json.Marshal(webhook)
req := httptest.NewRequest("POST", "/api/notifications/webhooks", bytes.NewReader(body))
w := httptest.NewRecorder()
h.CreateWebhook(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("GetNotificationHealth", func(t *testing.T) {
stats := map[string]int{
"pending": 1,
"sending": 2,
"sent": 10,
"failed": 0,
"dlq": 0,
}
mockManager.On("GetQueueStats").Return(stats, nil).Once()
mockManager.On("GetTelemetryStats", mock.Anything).Return(
notifications.TelemetryStats{},
nil,
).Once()
mockManager.On("GetEmailConfig").Return(notifications.EmailConfig{}).Once()
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{}).Once()
mockPersistence.On("IsEncryptionEnabled").Return(true).Once()
req := httptest.NewRequest("GET", "/api/notifications/health", nil)
w := httptest.NewRecorder()
h.GetNotificationHealth(w, req)
assert.Equal(t, 200, w.Code)
var resp map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
queue := resp["queue"].(map[string]interface{})
assert.Equal(t, float64(1), queue["pending"])
assert.Equal(t, true, queue["healthy"])
})
t.Run("GetAppriseConfig", func(t *testing.T) {
cfg := notifications.AppriseConfig{Enabled: true, APIKey: "secret-key"}
mockManager.On("GetAppriseConfig").Return(cfg).Once()
req := httptest.NewRequest("GET", "/api/notifications/apprise", nil)
w := httptest.NewRecorder()
h.GetAppriseConfig(w, req)
assert.Equal(t, 200, w.Code)
var resp map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
assert.NotContains(t, w.Body.String(), "secret-key") // Should be redacted
assert.Equal(t, true, resp["hasApiKey"])
})
t.Run("UpdateAppriseConfig", func(t *testing.T) {
cfg := notifications.AppriseConfig{Enabled: true}
mockManager.On("GetAppriseConfig").Return(cfg).Twice() // preserve lookup + response echo
mockManager.On("SetAppriseConfig", mock.Anything).Return().Once()
mockPersistence.On("SaveAppriseConfig", mock.Anything).Return(nil).Once()
body, _ := json.Marshal(cfg)
req := httptest.NewRequest("PUT", "/api/notifications/apprise", bytes.NewReader(body))
w := httptest.NewRecorder()
h.UpdateAppriseConfig(w, req)
assert.Equal(t, 200, w.Code)
})
t.Run("UpdateAppriseConfig_PreservesSavedAPIKeyWhenBlank", func(t *testing.T) {
existing := notifications.AppriseConfig{Enabled: true, APIKey: "saved-key", MinimumSeverity: "critical"}
mockManager.On("GetAppriseConfig").Return(existing).Twice() // preserve lookup + response echo
mockManager.On("SetAppriseConfig", mock.MatchedBy(func(cfg notifications.AppriseConfig) bool {
return cfg.APIKey == "saved-key" && cfg.MinimumSeverity == "critical"
})).Return().Once()
mockPersistence.On("SaveAppriseConfig", mock.MatchedBy(func(cfg notifications.AppriseConfig) bool {
return cfg.APIKey == "saved-key" && cfg.MinimumSeverity == "critical"
})).Return(nil).Once()
req := httptest.NewRequest(
"PUT",
"/api/notifications/apprise",
bytes.NewBufferString(`{"enabled":true,"apiKey":""}`),
)
w := httptest.NewRecorder()
h.UpdateAppriseConfig(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.NotContains(t, w.Body.String(), "saved-key") // Response should stay redacted
})
t.Run("UpdateAppriseConfig_ReplacesAPIKey", func(t *testing.T) {
mockManager.On("SetAppriseConfig", mock.MatchedBy(func(cfg notifications.AppriseConfig) bool {
return cfg.APIKey == "new-key"
})).Return().Once()
mockPersistence.On("SaveAppriseConfig", mock.MatchedBy(func(cfg notifications.AppriseConfig) bool {
return cfg.APIKey == "new-key"
})).Return(nil).Once()
mockManager.On("GetAppriseConfig").Return(notifications.AppriseConfig{Enabled: true, APIKey: "new-key"}).Twice()
req := httptest.NewRequest(
"PUT",
"/api/notifications/apprise",
bytes.NewBufferString(`{"enabled":true,"apiKey":"new-key"}`),
)
w := httptest.NewRecorder()
h.UpdateAppriseConfig(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.NotContains(t, w.Body.String(), "new-key") // Response should stay redacted
})
t.Run("UpdateWebhook", func(t *testing.T) {
webhook := notifications.WebhookConfig{ID: "wh1", Name: "Updated"}
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{{ID: "wh1"}}).Once()
mockManager.On("ValidateWebhookURL", mock.Anything).Return(nil).Once()
mockManager.On("UpdateWebhook", "wh1", mock.Anything).Return(nil).Once()
mockPersistence.On("SaveWebhooks", mock.Anything).Return(nil).Once()
body, _ := json.Marshal(webhook)
req := httptest.NewRequest("PUT", "/api/notifications/webhooks/wh1", bytes.NewReader(body))
w := httptest.NewRecorder()
h.UpdateWebhook(w, req)
assert.Equal(t, 200, w.Code)
})
t.Run("UpdateWebhook_PreservesOmittedTagRouting", func(t *testing.T) {
existing := notifications.WebhookConfig{
ID: "wh1",
URL: "https://example.com/hook",
TagFilter: []string{"customer:alpha", "critical"},
TagMode: "any",
MinimumSeverity: "critical",
}
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{existing}).Once()
mockManager.On("ValidateWebhookURL", existing.URL).Return(nil).Once()
mockManager.On(
"UpdateWebhook",
"wh1",
mock.MatchedBy(func(cfg notifications.WebhookConfig) bool {
return assert.ObjectsAreEqual(
[]string{"customer:alpha", "critical"},
cfg.TagFilter,
) && cfg.TagMode == "any" && cfg.MinimumSeverity == "critical"
}),
).Return(nil).Once()
mockPersistence.On("SaveWebhooks", mock.Anything).Return(nil).Once()
req := httptest.NewRequest(
"PUT",
"/api/notifications/webhooks/wh1",
bytes.NewBufferString(`{"name":"Updated","url":"https://example.com/hook"}`),
)
w := httptest.NewRecorder()
h.UpdateWebhook(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
assert.Equal(t, []interface{}{"customer:alpha", "critical"}, response["tagFilter"])
assert.Equal(t, "any", response["tagFilterMode"])
assert.Equal(t, "critical", response["minimumSeverity"])
})
t.Run("DeleteWebhook", func(t *testing.T) {
mockManager.On("DeleteWebhook", "wh1").Return(nil).Once()
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{{ID: "wh1"}}).Once()
mockPersistence.On("SaveWebhooks", mock.Anything).Return(nil).Once()
req := httptest.NewRequest("DELETE", "/api/notifications/webhooks/wh1", nil)
w := httptest.NewRecorder()
h.DeleteWebhook(w, req)
assert.Equal(t, 200, w.Code)
})
t.Run("GetWebhookTemplates", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/notifications/webhooks/templates", nil)
w := httptest.NewRecorder()
h.GetWebhookTemplates(w, req)
assert.Equal(t, 200, w.Code)
var templates []notifications.WebhookTemplate
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &templates))
findTemplate := func(service string) notifications.WebhookTemplate {
for _, tmpl := range templates {
if tmpl.Service == service {
return tmpl
}
}
return notifications.WebhookTemplate{}
}
discord := findTemplate("discord")
generic := findTemplate("generic")
assert.Equal(t, "Discord", discord.Label)
assert.Equal(t, "Generic", generic.Label)
assert.Equal(t, "@everyone or <@USER_ID> or <@&ROLE_ID>", discord.MentionPlaceholder)
assert.Equal(t, "Discord: Use @everyone, @here, <@USER_ID>, or <@&ROLE_ID>", discord.MentionHelp)
})
t.Run("GetWebhookHistory", func(t *testing.T) {
mockManager.On("GetWebhookHistory").Return([]notifications.WebhookDelivery{}).Once()
req := httptest.NewRequest("GET", "/api/notifications/webhooks/history", nil)
w := httptest.NewRecorder()
h.GetWebhookHistory(w, req)
assert.Equal(t, 200, w.Code)
})
t.Run("GetEmailProviders", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/notifications/email/providers", nil)
w := httptest.NewRecorder()
h.GetEmailProviders(w, req)
assert.Equal(t, 200, w.Code)
})
t.Run("HandleNotifications_Router", func(t *testing.T) {
routes := []struct {
method string
path string
setup func()
}{
{"GET", "/api/notifications/email", func() { mockManager.On("GetEmailConfig").Return(notifications.EmailConfig{}).Once() }},
{"PUT", "/api/notifications/email", func() {
mockManager.On("GetEmailConfig").Return(notifications.EmailConfig{}).Once()
mockManager.On("SetEmailConfig", mock.Anything).Return().Once()
mockPersistence.On("SaveEmailConfig", mock.Anything).Return(nil).Once()
}},
{"GET", "/api/notifications/apprise", func() { mockManager.On("GetAppriseConfig").Return(notifications.AppriseConfig{}).Once() }},
{"PUT", "/api/notifications/apprise", func() {
mockManager.On("SetAppriseConfig", mock.Anything).Return().Once()
mockPersistence.On("SaveAppriseConfig", mock.Anything).Return(nil).Once()
mockManager.On("GetAppriseConfig").Return(notifications.AppriseConfig{}).Twice() // preserve lookup + response echo
}},
{"GET", "/api/notifications/webhooks", func() { mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{}).Once() }},
{"POST", "/api/notifications/webhooks", func() {
mockManager.On("ValidateWebhookURL", mock.Anything).Return(nil).Once()
mockManager.On("AddWebhook", mock.Anything).Return().Once()
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{}).Once()
mockPersistence.On("SaveWebhooks", mock.Anything).Return(nil).Once()
}},
{"POST", "/api/notifications/webhooks/test", func() {
mockManager.On("TestEnhancedWebhook", mock.Anything).Return(200, "OK", nil).Once()
mockManager.On("IsEnabled").Return(true).Once()
}},
{"PUT", "/api/notifications/webhooks/wh1", func() {
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{{ID: "wh1"}}).Once()
mockManager.On("ValidateWebhookURL", mock.Anything).Return(nil).Once()
mockManager.On("UpdateWebhook", "wh1", mock.Anything).Return(nil).Once()
mockPersistence.On("SaveWebhooks", mock.Anything).Return(nil).Once()
}},
{"DELETE", "/api/notifications/webhooks/wh1", func() {
mockManager.On("DeleteWebhook", "wh1").Return(nil).Once()
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{{ID: "wh1"}}).Once()
mockPersistence.On("SaveWebhooks", mock.Anything).Return(nil).Once()
}},
{"GET", "/api/notifications/webhook-templates", func() {}},
{"GET", "/api/notifications/webhook-history", func() { mockManager.On("GetWebhookHistory").Return([]notifications.WebhookDelivery{}).Once() }},
{"GET", "/api/notifications/email-providers", func() {}},
{"GET", "/api/notifications/health", func() {
mockManager.On("GetQueueStats").Return(map[string]int{}, nil).Once()
mockManager.On("GetTelemetryStats", mock.Anything).Return(
notifications.TelemetryStats{},
nil,
).Once()
mockManager.On("GetEmailConfig").Return(notifications.EmailConfig{}).Once()
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{}).Once()
mockPersistence.On("IsEncryptionEnabled").Return(true).Once()
}},
}
for _, route := range routes {
t.Run(route.method+"_"+route.path, func(t *testing.T) {
route.setup()
var body []byte
if route.method == "POST" || route.method == "PUT" {
body = []byte("{}")
}
req := httptest.NewRequest(route.method, route.path, bytes.NewReader(body))
w := httptest.NewRecorder()
h.HandleNotifications(w, req)
assert.Equal(t, 200, w.Code)
})
}
// Test 404
req := httptest.NewRequest("GET", "/api/notifications/unknown", nil)
w := httptest.NewRecorder()
h.HandleNotifications(w, req)
assert.Equal(t, 404, w.Code)
})
t.Run("TestNotification", func(t *testing.T) {
mockManager.On("SendTestNotification", "email").Return(nil).Once()
mockManager.On("IsEnabled").Return(true).Once()
body, _ := json.Marshal(map[string]string{"method": "email"})
req := httptest.NewRequest("POST", "/api/notifications/test", bytes.NewReader(body))
w := httptest.NewRecorder()
h.TestNotification(w, req)
assert.Equal(t, 200, w.Code)
})
t.Run("TestNotification_Webhook", func(t *testing.T) {
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{{ID: "wh1"}}).Once()
mockManager.On("SendTestWebhook", mock.Anything).Return(nil).Once()
mockManager.On("IsEnabled").Return(true).Once()
body, _ := json.Marshal(map[string]string{"method": "webhook", "webhookId": "wh1"})
req := httptest.NewRequest("POST", "/api/notifications/test", bytes.NewReader(body))
w := httptest.NewRecorder()
h.TestNotification(w, req)
assert.Equal(t, 200, w.Code)
})
t.Run("TestWebhook", func(t *testing.T) {
mockManager.On("TestEnhancedWebhook", mock.Anything).Return(200, "OK", nil).Once()
mockManager.On("IsEnabled").Return(true).Once()
body, _ := json.Marshal(map[string]string{"url": "https://example.com/test", "service": "ntfy"})
req := httptest.NewRequest("POST", "/api/notifications/webhooks/test", bytes.NewReader(body))
w := httptest.NewRecorder()
h.TestWebhook(w, req)
assert.Equal(t, 200, w.Code)
})
t.Run("CreateWebhook_CanonicalizesPushoverLegacyAliasesAtAPIIngress", func(t *testing.T) {
mockManager.ExpectedCalls = nil
mockManager.Calls = nil
mockPersistence.ExpectedCalls = nil
mockPersistence.Calls = nil
mockManager.On("ValidateWebhookURL", "https://api.pushover.net/1/messages.json").Return(nil).Once()
mockManager.On("AddWebhook", mock.MatchedBy(func(w notifications.WebhookConfig) bool {
return w.Service == "pushover" &&
w.CustomFields["token"] == "legacy-app" &&
w.CustomFields["user"] == "legacy-user" &&
w.CustomFields["app_token"] == "" &&
w.CustomFields["user_token"] == ""
})).Return().Once()
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{}).Once()
mockPersistence.On("SaveWebhooks", mock.Anything).Return(nil).Once()
body, _ := json.Marshal(map[string]interface{}{
"name": "Pushover",
"url": "https://api.pushover.net/1/messages.json",
"service": "pushover",
"customFields": map[string]string{
"app_token": "legacy-app",
"user_token": "legacy-user",
},
})
req := httptest.NewRequest("POST", "/api/notifications/webhooks", bytes.NewReader(body))
w := httptest.NewRecorder()
h.CreateWebhook(w, req)
assert.Equal(t, 200, w.Code)
assert.NotContains(t, w.Body.String(), "app_token")
assert.NotContains(t, w.Body.String(), "user_token")
// Responses name the canonical fields but, like the list, mask their values.
assert.Contains(t, w.Body.String(), "\"token\":\"***REDACTED***\"")
assert.Contains(t, w.Body.String(), "\"user\":\"***REDACTED***\"")
assert.NotContains(t, w.Body.String(), "legacy-app")
assert.NotContains(t, w.Body.String(), "legacy-user")
})
t.Run("TestWebhook_UsesNotificationsOwnedTemplateSynthesis", func(t *testing.T) {
mockManager.On("TestEnhancedWebhook", mock.MatchedBy(func(webhook notifications.EnhancedWebhookConfig) bool {
return webhook.Service == "discord" &&
strings.Contains(webhook.PayloadTemplate, `"username": "Pulse Monitoring"`) &&
webhook.Headers["Content-Type"] == "application/json"
})).Return(200, "OK", nil).Once()
mockManager.On("IsEnabled").Return(true).Once()
body, _ := json.Marshal(map[string]string{"url": "https://example.com/test", "service": "discord"})
req := httptest.NewRequest("POST", "/api/notifications/webhooks/test", bytes.NewReader(body))
w := httptest.NewRecorder()
h.TestWebhook(w, req)
assert.Equal(t, 200, w.Code)
})
t.Run("TestWebhook_CanonicalizesPushoverLegacyAliasesAtAPIIngress", func(t *testing.T) {
mockManager.ExpectedCalls = nil
mockManager.Calls = nil
mockPersistence.ExpectedCalls = nil
mockPersistence.Calls = nil
mockManager.On("TestEnhancedWebhook", mock.MatchedBy(func(webhook notifications.EnhancedWebhookConfig) bool {
_, hasLegacyApp := webhook.CustomFields["app_token"]
_, hasLegacyUser := webhook.CustomFields["user_token"]
return webhook.Service == "pushover" &&
webhook.CustomFields["token"] == "legacy-app" &&
webhook.CustomFields["user"] == "legacy-user" &&
!hasLegacyApp &&
!hasLegacyUser
})).Return(200, "OK", nil).Once()
mockManager.On("IsEnabled").Return(true).Once()
body, _ := json.Marshal(map[string]interface{}{
"url": "https://api.pushover.net/1/messages.json",
"service": "pushover",
"customFields": map[string]string{
"app_token": "legacy-app",
"user_token": "legacy-user",
},
})
req := httptest.NewRequest("POST", "/api/notifications/webhooks/test", bytes.NewReader(body))
w := httptest.NewRecorder()
h.TestWebhook(w, req)
assert.Equal(t, 200, w.Code)
})
t.Run("TestNotification_EmailWithConfig", func(t *testing.T) {
mockManager.On("SendTestNotificationWithConfig", "email", mock.Anything, mock.Anything).Return(nil).Once()
mockManager.On("IsEnabled").Return(true).Once()
body, _ := json.Marshal(map[string]interface{}{
"method": "email",
"config": notifications.EmailConfig{Enabled: true, SMTPHost: "smtp.example.com", Password: "test"},
})
req := httptest.NewRequest("POST", "/api/notifications/test", bytes.NewReader(body))
w := httptest.NewRecorder()
h.TestNotification(w, req)
assert.Equal(t, 200, w.Code)
})
t.Run("TestNotification_AppriseWithConfig", func(t *testing.T) {
mockManager.On("SendTestAppriseWithConfig", mock.Anything).Return(nil).Once()
mockManager.On("IsEnabled").Return(true).Once()
body, _ := json.Marshal(map[string]interface{}{
"method": "apprise",
"config": notifications.AppriseConfig{Enabled: true, APIKey: "test"},
})
req := httptest.NewRequest("POST", "/api/notifications/test", bytes.NewReader(body))
w := httptest.NewRecorder()
h.TestNotification(w, req)
assert.Equal(t, 200, w.Code)
})
t.Run("TestNotification_AppriseWithConfig_UsesSavedAPIKeyWhenBlank", func(t *testing.T) {
saved := notifications.AppriseConfig{Enabled: true, APIKey: "saved-key"}
mockManager.On("GetAppriseConfig").Return(saved).Once()
mockManager.On("SendTestAppriseWithConfig", mock.MatchedBy(func(cfg notifications.AppriseConfig) bool {
return cfg.APIKey == "saved-key"
})).Return(nil).Once()
mockManager.On("IsEnabled").Return(true).Once()
body, _ := json.Marshal(map[string]interface{}{
"method": "apprise",
"config": notifications.AppriseConfig{Enabled: true},
})
req := httptest.NewRequest("POST", "/api/notifications/test", bytes.NewReader(body))
w := httptest.NewRecorder()
h.TestNotification(w, req)
assert.Equal(t, 200, w.Code)
})
t.Run("UpdateWebhook_PreserveRedacted", func(t *testing.T) {
existing := notifications.WebhookConfig{
ID: "wh1",
Headers: map[string]string{"Auth": "secret"},
CustomFields: map[string]string{"Key": "value"},
}
updated := notifications.WebhookConfig{
ID: "wh1",
URL: "https://example.com/new",
Headers: map[string]string{"Auth": "***REDACTED***"},
CustomFields: map[string]string{"Key": "***REDACTED***"},
}
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{existing}).Once()
mockManager.On("ValidateWebhookURL", "https://example.com/new").Return(nil).Once()
mockManager.On("UpdateWebhook", "wh1", mock.MatchedBy(func(w notifications.WebhookConfig) bool {
return w.Headers["Auth"] == "secret" && w.CustomFields["Key"] == "value"
})).Return(nil).Once()
mockPersistence.On("SaveWebhooks", mock.Anything).Return(nil).Once()
body, _ := json.Marshal(updated)
req := httptest.NewRequest("PUT", "/api/notifications/webhooks/wh1", bytes.NewReader(body))
w := httptest.NewRecorder()
h.UpdateWebhook(w, req)
assert.Equal(t, 200, w.Code)
})
t.Run("UpdateWebhook_CanonicalizesPushoverLegacyAliasesAtAPIIngress", func(t *testing.T) {
mockManager.ExpectedCalls = nil
mockManager.Calls = nil
mockPersistence.ExpectedCalls = nil
mockPersistence.Calls = nil
existing := notifications.WebhookConfig{
ID: "wh-push",
URL: "https://api.pushover.net/1/messages.json",
Service: "pushover",
}
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{existing}).Once()
mockManager.On("ValidateWebhookURL", "https://api.pushover.net/1/messages.json").Return(nil).Once()
mockManager.On("UpdateWebhook", "wh-push", mock.MatchedBy(func(w notifications.WebhookConfig) bool {
return w.Service == "pushover" &&
w.CustomFields["token"] == "legacy-app" &&
w.CustomFields["user"] == "legacy-user" &&
w.CustomFields["app_token"] == "" &&
w.CustomFields["user_token"] == ""
})).Return(nil).Once()
mockPersistence.On("SaveWebhooks", mock.Anything).Return(nil).Once()
body, _ := json.Marshal(map[string]interface{}{
"url": "https://api.pushover.net/1/messages.json",
"service": "pushover",
"customFields": map[string]string{
"app_token": "legacy-app",
"user_token": "legacy-user",
},
})
req := httptest.NewRequest("PUT", "/api/notifications/webhooks/wh-push", bytes.NewReader(body))
w := httptest.NewRecorder()
h.UpdateWebhook(w, req)
assert.Equal(t, 200, w.Code)
assert.NotContains(t, w.Body.String(), "app_token")
assert.NotContains(t, w.Body.String(), "user_token")
// Responses name the canonical fields but, like the list, mask their values.
assert.Contains(t, w.Body.String(), "\"token\":\"***REDACTED***\"")
assert.Contains(t, w.Body.String(), "\"user\":\"***REDACTED***\"")
assert.NotContains(t, w.Body.String(), "legacy-app")
assert.NotContains(t, w.Body.String(), "legacy-user")
})
t.Run("TestNotification_InvalidJSON", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/notifications/test", bytes.NewReader([]byte("{invalid}")))
w := httptest.NewRecorder()
h.TestNotification(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("TestNotification_RequestBodyTooLarge", func(t *testing.T) {
oversized := `{"method":"email","config":{"smtpHost":"` + strings.Repeat("a", notificationTestRequestBodyLimit) + `"}}`
req := httptest.NewRequest("POST", "/api/notifications/test", bytes.NewReader([]byte(oversized)))
w := httptest.NewRecorder()
h.TestNotification(w, req)
assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
})
t.Run("TestNotification_WebhookNotFound", func(t *testing.T) {
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{}).Once()
body, _ := json.Marshal(map[string]string{"method": "webhook", "webhookId": "nonexistent"})
req := httptest.NewRequest("POST", "/api/notifications/test", bytes.NewReader(body))
w := httptest.NewRecorder()
h.TestNotification(w, req)
assert.Equal(t, 404, w.Code)
})
t.Run("TestWebhook_RequestBodyTooLarge", func(t *testing.T) {
oversized := `{"url":"https://example.com/` + strings.Repeat("a", webhookTestRequestBodyLimit) + `","service":"generic"}`
req := httptest.NewRequest("POST", "/api/notifications/webhooks/test", bytes.NewReader([]byte(oversized)))
w := httptest.NewRecorder()
h.TestWebhook(w, req)
assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
})
}
func TestNotificationConfigPersistenceFailureDoesNotPublishRuntimeState(t *testing.T) {
newHandlers := func() (*NotificationHandlers, *MockNotificationManager, *MockNotificationConfigPersistence) {
monitor := new(MockNotificationMonitor)
manager := new(MockNotificationManager)
persistence := new(MockNotificationConfigPersistence)
monitor.On("GetNotificationManager").Return(manager)
monitor.On("GetConfigPersistence").Return(persistence)
return NewNotificationHandlers(nil, monitor), manager, persistence
}
t.Run("email", func(t *testing.T) {
h, manager, persistence := newHandlers()
manager.On("GetEmailConfig").Return(notifications.EmailConfig{Enabled: true}).Once()
persistence.On("SaveEmailConfig", mock.MatchedBy(func(cfg notifications.EmailConfig) bool {
return !cfg.Enabled
})).Return(errors.New("storage unavailable")).Once()
req := httptest.NewRequest(http.MethodPut, "/api/notifications/email", strings.NewReader(`{"enabled":false}`))
rec := httptest.NewRecorder()
h.UpdateEmailConfig(rec, req)
assert.Equal(t, http.StatusInternalServerError, rec.Code)
manager.AssertNotCalled(t, "SetEmailConfig", mock.Anything)
persistence.AssertExpectations(t)
})
t.Run("apprise", func(t *testing.T) {
h, manager, persistence := newHandlers()
manager.On("GetAppriseConfig").Return(notifications.AppriseConfig{Enabled: true}).Once()
persistence.On("SaveAppriseConfig", mock.MatchedBy(func(cfg notifications.AppriseConfig) bool {
return !cfg.Enabled
})).Return(errors.New("storage unavailable")).Once()
req := httptest.NewRequest(http.MethodPut, "/api/notifications/apprise", strings.NewReader(`{"enabled":false}`))
rec := httptest.NewRecorder()
h.UpdateAppriseConfig(rec, req)
assert.Equal(t, http.StatusInternalServerError, rec.Code)
manager.AssertNotCalled(t, "SetAppriseConfig", mock.Anything)
persistence.AssertExpectations(t)
})
t.Run("webhook create", func(t *testing.T) {
h, manager, persistence := newHandlers()
manager.On("ValidateWebhookURL", "https://example.com/new").Return(nil).Once()
manager.On("GetWebhooks").Return([]notifications.WebhookConfig{{ID: "existing"}}).Once()
persistence.On("SaveWebhooks", mock.MatchedBy(func(webhooks []notifications.WebhookConfig) bool {
return len(webhooks) == 2 && webhooks[1].Name == "new"
})).Return(errors.New("storage unavailable")).Once()
req := httptest.NewRequest(http.MethodPost, "/api/notifications/webhooks", strings.NewReader(`{"name":"new","url":"https://example.com/new"}`))
rec := httptest.NewRecorder()
h.CreateWebhook(rec, req)
assert.Equal(t, http.StatusInternalServerError, rec.Code)
manager.AssertNotCalled(t, "AddWebhook", mock.Anything)
persistence.AssertExpectations(t)
})
t.Run("webhook update", func(t *testing.T) {
h, manager, persistence := newHandlers()
existing := notifications.WebhookConfig{ID: "wh1", Name: "old", URL: "https://example.com/hook"}
manager.On("GetWebhooks").Return([]notifications.WebhookConfig{existing}).Once()
manager.On("ValidateWebhookURL", existing.URL).Return(nil).Once()
persistence.On("SaveWebhooks", mock.MatchedBy(func(webhooks []notifications.WebhookConfig) bool {
return len(webhooks) == 1 && webhooks[0].Name == "new"
})).Return(errors.New("storage unavailable")).Once()
req := httptest.NewRequest(http.MethodPut, "/api/notifications/webhooks/wh1", strings.NewReader(`{"name":"new","url":"https://example.com/hook"}`))
rec := httptest.NewRecorder()
h.UpdateWebhook(rec, req)
assert.Equal(t, http.StatusInternalServerError, rec.Code)
manager.AssertNotCalled(t, "UpdateWebhook", mock.Anything, mock.Anything)
persistence.AssertExpectations(t)
})
t.Run("webhook delete", func(t *testing.T) {
h, manager, persistence := newHandlers()
manager.On("GetWebhooks").Return([]notifications.WebhookConfig{{ID: "wh1"}}).Once()
persistence.On("SaveWebhooks", mock.MatchedBy(func(webhooks []notifications.WebhookConfig) bool {
return len(webhooks) == 0
})).Return(errors.New("storage unavailable")).Once()
req := httptest.NewRequest(http.MethodDelete, "/api/notifications/webhooks/wh1", nil)
rec := httptest.NewRecorder()
h.DeleteWebhook(rec, req)
assert.Equal(t, http.StatusInternalServerError, rec.Code)
manager.AssertNotCalled(t, "DeleteWebhook", mock.Anything)
persistence.AssertExpectations(t)
})
}
func TestClassifyNotificationError(t *testing.T) {
tests := []struct {
name string
err string
wantSummary string
wantHasDetail bool
summaryMustNot []string // substrings that must NOT appear in summary
}{
{
name: "connection refused",
err: "dial tcp smtp.gmail.com:587: connect: connection refused",
wantSummary: "Could not connect to the server — check host, port, and firewall settings",
wantHasDetail: true,
summaryMustNot: []string{"dial tcp"},
},
{
name: "no such host",
err: "dial tcp: lookup smtp.example.com: no such host",
wantSummary: "Server hostname not found — check the server address",
wantHasDetail: true,
summaryMustNot: []string{"dial tcp"},
},
{
name: "i/o timeout",
err: "dial tcp 10.0.0.1:587: i/o timeout",
wantSummary: "Connection timed out — the server may be unreachable or the port may be blocked",
wantHasDetail: true,
summaryMustNot: []string{"dial tcp"},
},
{
name: "context deadline exceeded",
err: "context deadline exceeded",
wantSummary: "Connection timed out — the server may be unreachable or the port may be blocked",
wantHasDetail: true,
summaryMustNot: []string{"context"},
},
{
name: "x509 certificate error",
err: "x509: certificate signed by unknown authority",
wantSummary: "TLS certificate error — check certificate settings or try enabling 'Skip TLS Verify'",
wantHasDetail: true,
summaryMustNot: []string{"x509:"},
},
{
name: "smtp auth failure",
err: "535 5.7.8 Username and Password not accepted",
wantSummary: "Authentication failed — check username and password",
wantHasDetail: true,
summaryMustNot: []string{"535"},
},
{
name: "executable not found",
err: `exec: "apprise": executable file not found in $PATH`,
wantSummary: "Required program not found — ensure it is installed on the server",
wantHasDetail: true,
summaryMustNot: []string{"exec:"},
},
{
name: "permission denied",
err: "open /etc/ssl/certs: permission denied",
wantSummary: "Permission denied — the server process lacks access to the required resource",
wantHasDetail: true,
summaryMustNot: []string{"open /etc"},
},
{
name: "eof",
err: "read tcp 192.168.1.1:587: EOF",
wantSummary: "The server closed the connection unexpectedly — check if TLS/StartTLS settings are correct",
wantHasDetail: true,
summaryMustNot: []string{"read tcp"},
},
{
name: "unknown error passes through",
err: "some unknown error",
wantSummary: "some unknown error",
wantHasDetail: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
summary, detail := classifyNotificationError(fmt.Errorf("%s", tt.err))
assert.Equal(t, tt.wantSummary, summary)
if tt.wantHasDetail {
assert.Equal(t, tt.err, detail, "detail should contain original error")
} else {
assert.Empty(t, detail, "detail should be empty for unclassified errors")
}
for _, banned := range tt.summaryMustNot {
assert.NotContains(t, summary, banned, "summary must not contain Go internal prefix %q", banned)
}
})
}
}
func TestWriteTestNotificationError(t *testing.T) {
t.Run("classified error returns JSON with detail", func(t *testing.T) {
w := httptest.NewRecorder()
err := fmt.Errorf("dial tcp smtp.gmail.com:587: connect: connection refused")
writeTestNotificationError(w, err, http.StatusBadRequest)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Header().Get("Content-Type"), "application/json")
var resp map[string]string
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "Could not connect to the server — check host, port, and firewall settings", resp["error"])
assert.Equal(t, "dial tcp smtp.gmail.com:587: connect: connection refused", resp["detail"])
})
t.Run("unclassified error returns JSON without detail", func(t *testing.T) {
w := httptest.NewRecorder()
err := fmt.Errorf("some unknown error")
writeTestNotificationError(w, err, http.StatusBadRequest)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]string
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "some unknown error", resp["error"])
assert.Empty(t, resp["detail"])
})
}
func TestNotificationHandlers_SetMonitorConcurrentAccess(t *testing.T) {
mockMonitor1 := new(MockNotificationMonitor)
mockMonitor2 := new(MockNotificationMonitor)
h := NewNotificationHandlers(nil, mockMonitor1)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func(worker int) {
defer wg.Done()
for j := 0; j < 500; j++ {
if (worker+j)%2 == 0 {
h.SetMonitor(mockMonitor1)
} else {
h.SetMonitor(mockMonitor2)
}
_ = h.getMonitor(context.Background())
}
}(i)
}
wg.Wait()
assert.NotNil(t, h.getMonitor(context.Background()))
}
// Webhook signing secrets must be masked on list reads and preserved when an
// update echoes the masked placeholder back.
func TestNotificationHandlersWebhookSigningSecretLifecycle(t *testing.T) {
mockMonitor := new(MockNotificationMonitor)
mockManager := new(MockNotificationManager)
mockPersistence := new(MockNotificationConfigPersistence)
mockMonitor.On("GetNotificationManager").Return(mockManager)
mockMonitor.On("GetConfigPersistence").Return(mockPersistence)
h := NewNotificationHandlers(nil, mockMonitor)
stored := notifications.WebhookConfig{
ID: "wh-signed",
Name: "Signed Webhook",
URL: "https://psa.example.com/inbound/pulse",
Enabled: true,
Service: "generic",
SigningSecret: "stored-secret",
}
t.Run("GetWebhooksMasksSigningSecret", func(t *testing.T) {
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{stored}).Once()
req := httptest.NewRequest("GET", "/api/notifications/webhooks", nil)
w := httptest.NewRecorder()
h.GetWebhooks(w, req)
assert.Equal(t, 200, w.Code)
var resp []map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, 1, len(resp))
assert.Equal(t, "***REDACTED***", resp[0]["signingSecret"])
})
t.Run("UpdateWebhookPreservesRedactedSigningSecret", func(t *testing.T) {
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{stored}).Once()
mockManager.On("ValidateWebhookURL", stored.URL).Return(nil).Once()
mockManager.On("UpdateWebhook", "wh-signed", mock.MatchedBy(func(w notifications.WebhookConfig) bool {
return w.SigningSecret == "stored-secret"
})).Return(nil).Once()
mockPersistence.On("SaveWebhooks", mock.Anything).Return(nil).Once()
update := stored
update.SigningSecret = "***REDACTED***"
body, _ := json.Marshal(update)
req := httptest.NewRequest("PUT", "/api/notifications/webhooks/wh-signed", bytes.NewReader(body))
w := httptest.NewRecorder()
h.UpdateWebhook(w, req)
assert.Equal(t, 200, w.Code)
mockManager.AssertExpectations(t)
})
}
// The webhook editor sends back the masked values GetWebhooks returned for
// every header, custom field and signing secret the user did not retype. Each
// masked value must resolve to its own saved value: never to the mask itself,
// never by discarding values typed in the same edit, and never back out in a
// response.
func TestWebhookMaskedValuesResolvePerKey(t *testing.T) {
newHandlers := func() (*NotificationHandlers, *MockNotificationManager, *MockNotificationConfigPersistence) {
monitor := new(MockNotificationMonitor)
manager := new(MockNotificationManager)
persistence := new(MockNotificationConfigPersistence)
monitor.On("GetNotificationManager").Return(manager)
monitor.On("GetConfigPersistence").Return(persistence)
return NewNotificationHandlers(nil, monitor), manager, persistence
}
saved := notifications.WebhookConfig{
ID: "wh-ops",
Name: "Ops",
URL: "https://hooks.example.com/ops",
Enabled: true,
Service: "pushover",
Headers: map[string]string{"Authorization": "Bearer saved-token", "Content-Type": "application/json"},
CustomFields: map[string]string{"token": "saved-app-token", "user": "saved-user-key"},
SigningSecret: "saved-signing-secret",
}
savedSecrets := []string{"saved-token", "saved-app-token", "saved-user-key", "saved-signing-secret"}
t.Run("update keeps values typed in the same edit", func(t *testing.T) {
h, manager, persistence := newHandlers()
var published notifications.WebhookConfig
manager.On("GetWebhooks").Return([]notifications.WebhookConfig{saved}).Once()
manager.On("ValidateWebhookURL", saved.URL).Return(nil).Once()
manager.On("UpdateWebhook", "wh-ops", mock.Anything).Run(func(args mock.Arguments) {
published = args.Get(1).(notifications.WebhookConfig)
}).Return(nil).Once()
persistence.On("SaveWebhooks", mock.Anything).Return(nil).Once()
body, _ := json.Marshal(map[string]interface{}{
"name": "Ops", "url": saved.URL, "enabled": true, "service": "pushover",
"headers": map[string]string{
"Authorization": "***REDACTED***",
"Content-Type": "***REDACTED***",
"X-Team": "platform",
},
"customFields": map[string]string{"token": "***REDACTED***", "user": "new-user-key"},
"signingSecret": "***REDACTED***",
})
rec := httptest.NewRecorder()
h.UpdateWebhook(rec, httptest.NewRequest(http.MethodPut, "/api/notifications/webhooks/wh-ops", bytes.NewReader(body)))
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, map[string]string{
"Authorization": "Bearer saved-token",
"Content-Type": "application/json",
"X-Team": "platform",
}, published.Headers)
assert.Equal(t, map[string]string{"token": "saved-app-token", "user": "new-user-key"}, published.CustomFields)
assert.Equal(t, "saved-signing-secret", published.SigningSecret)
for _, secret := range append(savedSecrets, "new-user-key", "platform") {
assert.NotContains(t, rec.Body.String(), secret, "update response must stay masked")
}
assert.Contains(t, rec.Body.String(), `"X-Team":"***REDACTED***"`)
})
t.Run("update never stores the mask or a cleared header", func(t *testing.T) {
h, manager, persistence := newHandlers()
var published notifications.WebhookConfig
manager.On("GetWebhooks").Return([]notifications.WebhookConfig{saved}).Once()
manager.On("ValidateWebhookURL", saved.URL).Return(nil).Once()
manager.On("UpdateWebhook", "wh-ops", mock.Anything).Run(func(args mock.Arguments) {
published = args.Get(1).(notifications.WebhookConfig)
}).Return(nil).Once()
persistence.On("SaveWebhooks", mock.Anything).Return(nil).Once()
body, _ := json.Marshal(map[string]interface{}{
"name": "Ops", "url": saved.URL, "enabled": true, "service": "pushover",
// Content-Type cleared in the editor; X-Unknown masked but never saved.
"headers": map[string]string{"Authorization": "***REDACTED***", "Content-Type": "", "X-Unknown": "***REDACTED***"},
"customFields": map[string]string{"token": "***REDACTED***", "user": "***REDACTED***"},
})
rec := httptest.NewRecorder()
h.UpdateWebhook(rec, httptest.NewRequest(http.MethodPut, "/api/notifications/webhooks/wh-ops", bytes.NewReader(body)))
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, map[string]string{"Authorization": "Bearer saved-token"}, published.Headers)
assert.Equal(t, "", published.SigningSecret, "an omitted signing secret is removed, as before")
})
t.Run("create drops masked values and responds masked", func(t *testing.T) {
h, manager, persistence := newHandlers()
var added notifications.WebhookConfig
manager.On("ValidateWebhookURL", "https://hooks.example.com/new").Return(nil).Once()
manager.On("GetWebhooks").Return([]notifications.WebhookConfig{}).Once()
manager.On("AddWebhook", mock.Anything).Run(func(args mock.Arguments) {
added = args.Get(0).(notifications.WebhookConfig)
}).Return().Once()
persistence.On("SaveWebhooks", mock.Anything).Return(nil).Once()
body, _ := json.Marshal(map[string]interface{}{
"name": "New", "url": "https://hooks.example.com/new", "enabled": true,
"headers": map[string]string{"Authorization": "***REDACTED***", "X-Api-Key": "typed-key"},
"signingSecret": "typed-signing-secret",
})
rec := httptest.NewRecorder()
h.CreateWebhook(rec, httptest.NewRequest(http.MethodPost, "/api/notifications/webhooks", bytes.NewReader(body)))
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, map[string]string{"X-Api-Key": "typed-key"}, added.Headers)
assert.Equal(t, "typed-signing-secret", added.SigningSecret)
assert.NotContains(t, rec.Body.String(), "typed-key")
assert.NotContains(t, rec.Body.String(), "typed-signing-secret")
assert.Contains(t, rec.Body.String(), `"signingSecret":"***REDACTED***"`)
})
t.Run("form test of a saved webhook sends its saved values", func(t *testing.T) {
h, manager, _ := newHandlers()
var tested notifications.EnhancedWebhookConfig
manager.On("GetWebhooks").Return([]notifications.WebhookConfig{saved}).Once()
manager.On("TestEnhancedWebhook", mock.Anything).Run(func(args mock.Arguments) {
tested = args.Get(0).(notifications.EnhancedWebhookConfig)
}).Return(200, "OK", nil).Once()
manager.On("IsEnabled").Return(true).Once()
// The edit form posts its whole state, including the saved id.
body, _ := json.Marshal(map[string]interface{}{
"id": "wh-ops", "name": "Ops", "url": saved.URL, "enabled": true, "service": "pushover",
"headers": map[string]string{"Authorization": "***REDACTED***", "X-Team": "platform"},
"customFields": map[string]string{"token": "***REDACTED***", "user": "***REDACTED***"},
"signingSecret": "***REDACTED***",
})
rec := httptest.NewRecorder()
h.TestWebhook(rec, httptest.NewRequest(http.MethodPost, "/api/notifications/webhooks/test", bytes.NewReader(body)))
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "Bearer saved-token", tested.Headers["Authorization"])
assert.Equal(t, "platform", tested.Headers["X-Team"])
assert.Equal(t, "saved-app-token", tested.CustomFields["token"])
assert.Equal(t, "saved-user-key", tested.CustomFields["user"])
assert.Equal(t, "saved-signing-secret", tested.SigningSecret)
})
t.Run("form test of an unsaved webhook never sends the mask", func(t *testing.T) {
h, manager, _ := newHandlers()
var tested notifications.EnhancedWebhookConfig
manager.On("TestEnhancedWebhook", mock.Anything).Run(func(args mock.Arguments) {
tested = args.Get(0).(notifications.EnhancedWebhookConfig)
}).Return(200, "OK", nil).Once()
manager.On("IsEnabled").Return(true).Once()
body, _ := json.Marshal(map[string]interface{}{
"url": "https://hooks.example.com/new",
"headers": map[string]string{"Authorization": "***REDACTED***", "X-Team": "platform"},
})
rec := httptest.NewRecorder()
h.TestWebhook(rec, httptest.NewRequest(http.MethodPost, "/api/notifications/webhooks/test", bytes.NewReader(body)))
assert.Equal(t, http.StatusOK, rec.Code)
_, hasAuthorization := tested.Headers["Authorization"]
assert.False(t, hasAuthorization, "a mask with no saved value must not be sent")
assert.Equal(t, "platform", tested.Headers["X-Team"])
manager.AssertNotCalled(t, "GetWebhooks")
})
}