mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 04:59:43 +00:00
f279dc0b07
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.
1556 lines
60 KiB
Go
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")
|
|
})
|
|
}
|