mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 05:57:27 +00:00
c2d59d2747
Treat explicit empty origins as a durable same-origin policy, preserve omitted fields and deployment overrides, and expose the effective value. Resolve webhook masks consistently across Create, Update and Test; reject unresolved or ambiguous identities before mutation or delivery while retaining masked responses, per-key edits, clears and encrypted persistence. Include real middleware/reload and HTTP Test/edit/delivery controls with all affected and shared dependent contracts. Change-source: pulse-maintainer
2061 lines
84 KiB
Go
2061 lines
84 KiB
Go
package alerting
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"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)
|
|
})
|
|
}
|
|
|
|
// A masked value means keep that value, not ignore the rest of the edit.
|
|
// All values here are synthetic; neither the response nor errors may reveal
|
|
// a preserved credential the editor did not submit.
|
|
func TestWebhookEditPreservesOnlyMaskedValues(t *testing.T) {
|
|
const masked = "***REDACTED***"
|
|
stored := notifications.WebhookConfig{ID: "edit", URL: "https://example.test/hook", Enabled: true,
|
|
Headers: map[string]string{"Authorization": "synthetic-auth", "Content-Type": "text/plain", "X-Remove": "old"},
|
|
CustomFields: map[string]string{"token": "synthetic-token", "channel": "old", "remove": "old"}}
|
|
cases := []struct {
|
|
name string
|
|
headers map[string]string
|
|
fields map[string]string
|
|
wantHeaders map[string]string
|
|
wantFields map[string]string
|
|
invalid bool
|
|
}{
|
|
{"edit and remove beside masks", map[string]string{"Authorization": masked, "Content-Type": "application/json", "X-New": "new"},
|
|
map[string]string{"token": masked, "channel": "new", "added": "new"},
|
|
map[string]string{"Authorization": "synthetic-auth", "Content-Type": "application/json", "X-New": "new"},
|
|
map[string]string{"token": "synthetic-token", "channel": "new", "added": "new"}, false},
|
|
{"clear beside masks", map[string]string{"Authorization": masked, "Content-Type": ""},
|
|
map[string]string{"token": masked, "channel": ""},
|
|
map[string]string{"Authorization": "synthetic-auth"},
|
|
map[string]string{"token": "synthetic-token", "channel": ""}, false},
|
|
{"header case is insensitive", map[string]string{"authorization": masked}, map[string]string{"token": masked},
|
|
map[string]string{"authorization": "synthetic-auth"}, map[string]string{"token": "synthetic-token"}, false},
|
|
{"explicit empty maps", map[string]string{}, map[string]string{}, map[string]string{}, map[string]string{}, false},
|
|
{"omitted maps keep replacement semantics", nil, nil, nil, nil, false},
|
|
{"unknown masked header", map[string]string{"X-Unknown": masked}, nil, nil, nil, true},
|
|
{"unknown masked field", nil, map[string]string{"unknown": masked}, nil, nil, true},
|
|
{"custom field case is significant", nil, map[string]string{"Token": masked}, nil, nil, true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
manager := new(MockNotificationManager)
|
|
persistence := new(MockNotificationConfigPersistence)
|
|
monitor := new(MockNotificationMonitor)
|
|
monitor.On("GetNotificationManager").Return(manager)
|
|
monitor.On("GetConfigPersistence").Return(persistence)
|
|
manager.On("GetWebhooks").Return([]notifications.WebhookConfig{stored}).Once()
|
|
if !tc.invalid {
|
|
manager.On("ValidateWebhookURL", stored.URL).Return(nil).Once()
|
|
matches := func(got notifications.WebhookConfig) bool {
|
|
return reflect.DeepEqual(got.Headers, tc.wantHeaders) && reflect.DeepEqual(got.CustomFields, tc.wantFields)
|
|
}
|
|
persistence.On("SaveWebhooks", mock.MatchedBy(func(got []notifications.WebhookConfig) bool {
|
|
return len(got) == 1 && matches(got[0])
|
|
})).Return(nil).Once()
|
|
manager.On("UpdateWebhook", stored.ID, mock.MatchedBy(matches)).Return(nil).Once()
|
|
}
|
|
incoming := stored
|
|
incoming.Headers, incoming.CustomFields = tc.headers, tc.fields
|
|
// Marshal maps explicitly: WebhookConfig's omitempty would turn an
|
|
// explicit empty CustomFields map into an omitted field instead.
|
|
form := map[string]any{"id": incoming.ID, "url": incoming.URL, "enabled": incoming.Enabled}
|
|
if tc.headers != nil {
|
|
form["headers"] = tc.headers
|
|
}
|
|
if tc.fields != nil {
|
|
form["customFields"] = tc.fields
|
|
}
|
|
body, err := json.Marshal(form)
|
|
assert.NoError(t, err)
|
|
rec := httptest.NewRecorder()
|
|
NewNotificationHandlers(nil, monitor).UpdateWebhook(rec, httptest.NewRequest(http.MethodPut,
|
|
"/api/notifications/webhooks/"+stored.ID, bytes.NewReader(body)))
|
|
wantStatus := http.StatusOK
|
|
if tc.invalid {
|
|
wantStatus = http.StatusBadRequest
|
|
manager.AssertNotCalled(t, "UpdateWebhook", mock.Anything, mock.Anything)
|
|
persistence.AssertNotCalled(t, "SaveWebhooks", mock.Anything)
|
|
}
|
|
assert.Equal(t, wantStatus, rec.Code, rec.Body.String())
|
|
assert.NotContains(t, rec.Body.String(), "synthetic-auth")
|
|
assert.NotContains(t, rec.Body.String(), "synthetic-token")
|
|
if !tc.invalid {
|
|
var response notifications.WebhookConfig
|
|
assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response))
|
|
var expectedFields map[string]string
|
|
if len(incoming.CustomFields) > 0 {
|
|
expectedFields = make(map[string]string, len(incoming.CustomFields))
|
|
for key := range incoming.CustomFields {
|
|
expectedFields[key] = maskedWebhookSecret
|
|
}
|
|
}
|
|
assert.Equal(t, expectedFields, response.CustomFields)
|
|
}
|
|
assert.Equal(t, "old", stored.CustomFields["channel"], "edit mutated the previous map")
|
|
assert.Equal(t, "text/plain", stored.Headers["Content-Type"], "edit mutated the previous map")
|
|
manager.AssertExpectations(t)
|
|
persistence.AssertExpectations(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWebhookEditRejectsUnrecoverableMasks(t *testing.T) {
|
|
for _, stored := range []map[string]string{
|
|
{"Authorization": "***REDACTED***"},
|
|
{"AUTHORIZATION": "synthetic-first", "authorization": "synthetic-second"},
|
|
} {
|
|
manager := new(MockNotificationManager)
|
|
persistence := new(MockNotificationConfigPersistence)
|
|
monitor := new(MockNotificationMonitor)
|
|
monitor.On("GetNotificationManager").Return(manager)
|
|
monitor.On("GetConfigPersistence").Return(persistence)
|
|
manager.On("GetWebhooks").Return([]notifications.WebhookConfig{{ID: "edit", Headers: stored}}).Once()
|
|
rec := httptest.NewRecorder()
|
|
NewNotificationHandlers(nil, monitor).UpdateWebhook(rec, httptest.NewRequest(http.MethodPut,
|
|
"/api/notifications/webhooks/edit", strings.NewReader(`{"headers":{"Authorization":"***REDACTED***"}}`)))
|
|
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
|
assert.NotContains(t, rec.Body.String(), "synthetic-first")
|
|
assert.NotContains(t, rec.Body.String(), "synthetic-second")
|
|
manager.AssertNotCalled(t, "UpdateWebhook", mock.Anything, mock.Anything)
|
|
persistence.AssertNotCalled(t, "SaveWebhooks", mock.Anything)
|
|
manager.AssertExpectations(t)
|
|
}
|
|
}
|
|
|
|
type webhookEditMonitor struct {
|
|
manager *notifications.NotificationManager
|
|
persistence *config.ConfigPersistence
|
|
}
|
|
|
|
func (m *webhookEditMonitor) GetNotificationManager() NotificationManager { return m.manager }
|
|
func (m *webhookEditMonitor) GetConfigPersistence() NotificationConfigPersistence {
|
|
return m.persistence
|
|
}
|
|
|
|
// This complements the #2540 single/static-header control: a real masked form
|
|
// edit with another saved credential, grouped firing/recovery, disable-grouping
|
|
// flush and the persistent autonomous sender. No native Telegram is contacted.
|
|
func TestWebhookMaskedEditPersistsAndDelivers(t *testing.T) {
|
|
for _, service := range []string{"telegram", "generic"} {
|
|
for _, mode := range []string{"grouped", "disable-pending-group"} {
|
|
t.Run(service+"/"+mode, func(t *testing.T) {
|
|
type delivery struct {
|
|
header http.Header
|
|
text, channel, chat, event string
|
|
}
|
|
var mu sync.Mutex
|
|
var received []delivery
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
Text string `json:"text"`
|
|
Channel string `json:"channel"`
|
|
Chat string `json:"chat_id"`
|
|
Event string `json:"event"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
t.Error(err)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
if r.Header.Get("Content-Type") != "application/json" || payload.Text == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
mu.Lock()
|
|
received = append(received, delivery{r.Header.Clone(), payload.Text, payload.Channel, payload.Chat, payload.Event})
|
|
mu.Unlock()
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
dir := t.TempDir()
|
|
persistence := config.NewConfigPersistence(dir)
|
|
if !persistence.IsEncryptionEnabled() {
|
|
t.Fatal("connected control requires encrypted save/load")
|
|
}
|
|
hook := notifications.WebhookConfig{ID: "edit", Name: "edit", Enabled: true, Method: "POST", Service: service,
|
|
URL: server.URL + "/hook?chat_id=-1001234",
|
|
Headers: map[string]string{"Authorization": "synthetic-auth", "Content-Type": "text/plain", "X-Route": "old", "X-Remove": "old"},
|
|
CustomFields: map[string]string{"token": "synthetic-token", "channel": "old", "remove": "old"}}
|
|
if service == "generic" {
|
|
hook.Template = `{"text":"{{.Message | jsonString}}","channel":"{{.CustomFields.channel | jsonString}}","event":"{{.Event}}"}`
|
|
}
|
|
if err := persistence.SaveWebhooks([]notifications.WebhookConfig{hook}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
open := func() *notifications.NotificationManager {
|
|
t.Helper()
|
|
loaded, err := config.NewConfigPersistence(dir).LoadWebhooks()
|
|
if err != nil || len(loaded) != 1 {
|
|
t.Fatalf("saved destination unavailable: %v", err)
|
|
}
|
|
m := notifications.NewNotificationManagerWithDeferredQueue("", dir)
|
|
if err := m.UpdateAllowedPrivateCIDRs("127.0.0.1/32,::1/128"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.AddWebhook(loaded[0])
|
|
m.SetNotifyOnResolve(true)
|
|
m.SetGroupingConfig(true, 1, true, false)
|
|
t.Cleanup(m.Stop)
|
|
return m
|
|
}
|
|
m := open()
|
|
monitor := &webhookEditMonitor{m, persistence}
|
|
handler := NewNotificationHandlers(nil, monitor)
|
|
listed := httptest.NewRecorder()
|
|
handler.GetWebhooks(listed, httptest.NewRequest(http.MethodGet, "/api/notifications/webhooks", nil))
|
|
var editable []notifications.WebhookConfig
|
|
if err := json.Unmarshal(listed.Body.Bytes(), &editable); err != nil || len(editable) != 1 {
|
|
t.Fatal("cannot read masked destination")
|
|
}
|
|
edit := editable[0]
|
|
if edit.Headers["Authorization"] != "***REDACTED***" || edit.CustomFields["token"] != "***REDACTED***" {
|
|
t.Fatal("list disclosed credentials")
|
|
}
|
|
edit.Headers["authorization"] = edit.Headers["Authorization"]
|
|
delete(edit.Headers, "Authorization")
|
|
edit.Headers["Content-Type"], edit.Headers["X-Route"] = "application/json", "new"
|
|
delete(edit.Headers, "X-Remove")
|
|
edit.CustomFields["channel"] = "new"
|
|
delete(edit.CustomFields, "remove")
|
|
body, err := json.Marshal(edit)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
updated := httptest.NewRecorder()
|
|
handler.UpdateWebhook(updated, httptest.NewRequest(http.MethodPut, "/api/notifications/webhooks/edit", bytes.NewReader(body)))
|
|
if updated.Code != http.StatusOK {
|
|
t.Fatal("masked update failed")
|
|
}
|
|
if strings.Contains(updated.Body.String(), "synthetic-token") {
|
|
t.Error("masked update exposed a stored credential")
|
|
}
|
|
m.Stop()
|
|
m = open()
|
|
monitor.manager = m
|
|
saved := m.GetWebhooks()[0]
|
|
if saved.Service != service || saved.Template != hook.Template || saved.URL != hook.URL ||
|
|
saved.Headers["authorization"] != "synthetic-auth" || saved.Headers["Content-Type"] != "application/json" ||
|
|
saved.Headers["X-Route"] != "new" || len(saved.Headers) != 3 ||
|
|
saved.CustomFields["token"] != "synthetic-token" || saved.CustomFields["channel"] != "new" || len(saved.CustomFields) != 2 {
|
|
t.Fatal("masked edit did not survive encrypted reload with explicit edits/removals intact")
|
|
}
|
|
encrypted, err := os.ReadFile(filepath.Join(dir, "webhooks.enc"))
|
|
if err != nil || bytes.Contains(encrypted, []byte("synthetic-token")) || bytes.Contains(encrypted, []byte("synthetic-auth")) {
|
|
t.Fatal("saved credentials were not encrypted")
|
|
}
|
|
wait := func(done func() bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(15 * time.Second)
|
|
for !done() {
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("connected delivery did not complete")
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
batch := []*alerts.Alert{
|
|
{ID: "edit-alpha", ResourceName: "edit-alpha", Node: "node", Type: "cpu", Message: "edit-alpha CPU above threshold", Level: alerts.AlertLevelWarning, StartTime: time.Now().Add(-time.Minute)},
|
|
{ID: "edit-bravo", ResourceName: "edit-bravo", Node: "node", Type: "cpu", Message: "edit-bravo CPU above threshold", Level: alerts.AlertLevelWarning, StartTime: time.Now().Add(-time.Minute)},
|
|
}
|
|
for _, a := range batch {
|
|
m.SendAlert(a)
|
|
}
|
|
wantFiring := 1
|
|
if mode == "disable-pending-group" {
|
|
m.SetGroupingConfig(false, 600, true, false)
|
|
wantFiring = 2
|
|
}
|
|
wait(func() bool { stats, err := m.GetQueueStats(); return err == nil && stats["pending"] == wantFiring })
|
|
pending, err := m.GetQueue().GetPending(10)
|
|
if err != nil || len(pending) != wantFiring {
|
|
t.Fatal("ordinary grouping/disable policy produced the wrong number of jobs")
|
|
}
|
|
for _, row := range pending {
|
|
var queued notifications.WebhookConfig
|
|
if err := json.Unmarshal(row.Config, &queued); err != nil || queued.Headers["X-Route"] != "new" || queued.CustomFields["channel"] != "new" {
|
|
t.Fatal("new admission did not snapshot the edited destination")
|
|
}
|
|
if len(row.Alerts) != 3-wantFiring {
|
|
t.Fatal("grouped log did not match actual group membership")
|
|
}
|
|
}
|
|
m.StartQueueProcessing()
|
|
wait(func() bool { stats, err := m.GetQueueStats(); return err == nil && stats["sent"] == wantFiring })
|
|
// Reopen after firing so recovery relies on durable receipts.
|
|
m.Stop()
|
|
m = open()
|
|
if mode == "disable-pending-group" {
|
|
m.SetGroupingConfig(false, 600, true, false)
|
|
}
|
|
for _, a := range batch {
|
|
m.SendResolvedAlert(&alerts.ResolvedAlert{Alert: a, ResolvedTime: time.Now()})
|
|
}
|
|
m.StartQueueProcessing()
|
|
wantTotal := wantFiring * 2
|
|
wait(func() bool { stats, err := m.GetQueueStats(); return err == nil && stats["sent"] == wantTotal })
|
|
wait(func() bool {
|
|
entries, err := m.GetDeliveryLog(time.Now().Add(-time.Hour), 20)
|
|
return err == nil && len(entries) == wantTotal
|
|
})
|
|
entries, err := m.GetDeliveryLog(time.Now().Add(-time.Hour), 20)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, e := range entries {
|
|
if !e.Success || e.Outcome != notifications.DeliveryOutcomeSent || e.Attempts != 1 || e.AlertCount != 3-wantFiring {
|
|
t.Fatal("delivery audit lost exact attempt or group membership")
|
|
}
|
|
}
|
|
m.Stop()
|
|
mu.Lock()
|
|
got := append([]delivery(nil), received...)
|
|
mu.Unlock()
|
|
if len(got) != wantTotal {
|
|
t.Fatalf("received %d payloads, want %d", len(got), wantTotal)
|
|
}
|
|
for phase := 0; phase < 2; phase++ {
|
|
combined := ""
|
|
for _, d := range got[phase*wantFiring : (phase+1)*wantFiring] {
|
|
if d.header.Get("Authorization") != "synthetic-auth" || d.header.Get("X-Route") != "new" || d.header.Get("X-Remove") != "" {
|
|
t.Fatal("delivery lost an explicit edit or masked credential")
|
|
}
|
|
if service == "telegram" && d.chat != "-1001234" {
|
|
t.Fatal("saved Telegram service/chat did not reach delivery")
|
|
}
|
|
if service == "generic" && d.channel != "new" {
|
|
t.Fatal("custom field edit did not reach the custom payload")
|
|
}
|
|
if phase == 1 && ((service == "telegram" && !strings.Contains(strings.ToLower(d.text), "resolved")) || (service == "generic" && d.event != "resolved")) {
|
|
t.Fatal("recovery rendered as a firing")
|
|
}
|
|
combined += d.text
|
|
}
|
|
for _, a := range batch {
|
|
if !strings.Contains(combined, a.ResourceName) {
|
|
t.Fatal("grouped/individual payload lost an alert")
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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; known masked values stay saved.
|
|
// Unknown masks are rejected by TestWebhookEditPreservesOnlyMaskedValues.
|
|
"headers": map[string]string{"Authorization": "***REDACTED***", "Content-Type": ""},
|
|
"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 keeps literal 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{"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 rejects the mask before sending", func(t *testing.T) {
|
|
h, manager, _ := newHandlers()
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"url": "https://hooks.example.com/new",
|
|
"headers": map[string]string{"Authorization": maskedWebhookSecret, "X-Team": "platform"},
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
h.TestWebhook(rec, httptest.NewRequest(http.MethodPost, "/api/notifications/webhooks/test", bytes.NewReader(body)))
|
|
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
|
manager.AssertNotCalled(t, "TestEnhancedWebhook", mock.Anything)
|
|
manager.AssertNotCalled(t, "GetWebhooks")
|
|
})
|
|
}
|
|
|
|
// A masked form must fail before persistence or a Test send if its saved
|
|
// identity is missing or ambiguous. Test and Update must resolve the same map.
|
|
func TestWebhookPlaceholderBoundaryAgreement(t *testing.T) {
|
|
for _, route := range []string{"update", "test", "create"} {
|
|
for _, tc := range []struct {
|
|
name string
|
|
savedHeaders, incomingHeaders, savedFields, incomingFields map[string]string
|
|
savedSigning, incomingSigning string
|
|
valid bool
|
|
}{
|
|
{"case varied saved header", map[string]string{"Authorization": "synthetic-auth"}, map[string]string{"authorization": maskedWebhookSecret}, nil, nil, "", "", true},
|
|
{"unknown header", nil, map[string]string{"X-Missing": maskedWebhookSecret}, nil, nil, "", "", false},
|
|
{"stored header is a mask", map[string]string{"Authorization": maskedWebhookSecret}, map[string]string{"Authorization": maskedWebhookSecret}, nil, nil, "", "", false},
|
|
{"exact match also has conflicting alias", map[string]string{"Authorization": "synthetic-one", "authorization": "synthetic-two"}, map[string]string{"Authorization": maskedWebhookSecret}, nil, nil, "", "", false},
|
|
{"incoming aliases conflict", nil, map[string]string{"X-Route": "one", "x-route": "two"}, nil, nil, "", "", false},
|
|
{"custom fields are case sensitive", nil, nil, map[string]string{"Token": "synthetic-token"}, map[string]string{"token": maskedWebhookSecret}, "", "", false},
|
|
{"missing signing secret", nil, nil, nil, nil, "", maskedWebhookSecret, false},
|
|
{"saved signing secret is a mask", nil, nil, nil, nil, maskedWebhookSecret, maskedWebhookSecret, false},
|
|
} {
|
|
if route == "create" && tc.valid {
|
|
continue
|
|
}
|
|
t.Run(route+"/"+tc.name, func(t *testing.T) {
|
|
manager := new(MockNotificationManager)
|
|
persistence := new(MockNotificationConfigPersistence)
|
|
monitor := new(MockNotificationMonitor)
|
|
monitor.On("GetNotificationManager").Return(manager)
|
|
monitor.On("GetConfigPersistence").Return(persistence)
|
|
saved := notifications.WebhookConfig{ID: "saved", URL: "https://hooks.example.test/alerts", Headers: tc.savedHeaders, CustomFields: tc.savedFields, SigningSecret: tc.savedSigning}
|
|
manager.On("GetWebhooks").Return([]notifications.WebhookConfig{saved}).Maybe()
|
|
manager.On("ValidateWebhookURL", mock.Anything).Return(nil).Maybe()
|
|
manager.On("IsEnabled").Return(true).Maybe()
|
|
manager.On("UpdateWebhook", mock.Anything, mock.Anything).Return(nil).Maybe()
|
|
manager.On("AddWebhook", mock.Anything).Return().Maybe()
|
|
persistence.On("SaveWebhooks", mock.Anything).Return(nil).Maybe()
|
|
var tested notifications.EnhancedWebhookConfig
|
|
manager.On("TestEnhancedWebhook", mock.Anything).Run(func(a mock.Arguments) { tested = a.Get(0).(notifications.EnhancedWebhookConfig) }).Return(200, "OK", nil).Maybe()
|
|
incoming := notifications.WebhookConfig{ID: "saved", URL: saved.URL, Headers: tc.incomingHeaders, CustomFields: tc.incomingFields, SigningSecret: tc.incomingSigning}
|
|
body, err := json.Marshal(incoming)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h := NewNotificationHandlers(nil, monitor)
|
|
switch route {
|
|
case "create":
|
|
h.CreateWebhook(rec, httptest.NewRequest(http.MethodPost, "/api/notifications/webhooks", bytes.NewReader(body)))
|
|
case "update":
|
|
h.UpdateWebhook(rec, httptest.NewRequest(http.MethodPut, "/api/notifications/webhooks/saved", bytes.NewReader(body)))
|
|
case "test":
|
|
h.TestWebhook(rec, httptest.NewRequest(http.MethodPost, "/api/notifications/webhooks/test", bytes.NewReader(body)))
|
|
}
|
|
if tc.valid {
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
if route == "test" && tested.Headers["authorization"] != "synthetic-auth" {
|
|
t.Fatal("Test silently dropped the case-varied saved credential")
|
|
}
|
|
} else {
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400", rec.Code)
|
|
}
|
|
manager.AssertNotCalled(t, "TestEnhancedWebhook", mock.Anything)
|
|
manager.AssertNotCalled(t, "UpdateWebhook", mock.Anything, mock.Anything)
|
|
manager.AssertNotCalled(t, "AddWebhook", mock.Anything)
|
|
persistence.AssertNotCalled(t, "SaveWebhooks", mock.Anything)
|
|
}
|
|
for _, secret := range []string{"synthetic-auth", "synthetic-one", "synthetic-two", "synthetic-token"} {
|
|
if strings.Contains(rec.Body.String(), secret) {
|
|
t.Error("response disclosed a synthetic saved value")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWebhookSavedFormTestMatchesEncryptedEdit(t *testing.T) {
|
|
type observation struct {
|
|
header http.Header
|
|
payload string
|
|
}
|
|
var mu sync.Mutex
|
|
var observed []observation
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var payload json.RawMessage
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
w.WriteHeader(400)
|
|
return
|
|
}
|
|
mu.Lock()
|
|
observed = append(observed, observation{r.Header.Clone(), string(payload)})
|
|
mu.Unlock()
|
|
fmt.Fprint(w, "OK")
|
|
}))
|
|
defer server.Close()
|
|
dir := t.TempDir()
|
|
persistence := config.NewConfigPersistence(dir)
|
|
saved := notifications.WebhookConfig{ID: "saved-form", Name: "Ops", URL: server.URL, Enabled: true, Service: "generic", Headers: map[string]string{"Authorization": "synthetic-auth", "Content-Type": "text/plain", "X-Delete": "remove"}, CustomFields: map[string]string{"token": "synthetic-token", "channel": "old", "delete": "remove"}, SigningSecret: "synthetic-signing"}
|
|
if err := persistence.SaveWebhooks([]notifications.WebhookConfig{saved}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
manager := notifications.NewNotificationManagerWithDeferredQueue("", dir)
|
|
defer manager.Stop()
|
|
if err := manager.UpdateAllowedPrivateCIDRs("127.0.0.1/32,::1/128"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
manager.AddWebhook(saved)
|
|
liveBefore := manager.GetWebhooks()[0]
|
|
h := NewNotificationHandlers(nil, &webhookEditMonitor{manager, persistence})
|
|
listed := httptest.NewRecorder()
|
|
h.GetWebhooks(listed, httptest.NewRequest(http.MethodGet, "/api/notifications/webhooks", nil))
|
|
var editable []notifications.WebhookConfig
|
|
if err := json.Unmarshal(listed.Body.Bytes(), &editable); err != nil || len(editable) != 1 {
|
|
t.Fatal("masked saved form unavailable")
|
|
}
|
|
form := editable[0]
|
|
form.Headers["authorization"] = form.Headers["Authorization"]
|
|
delete(form.Headers, "Authorization")
|
|
form.Headers["Content-Type"] = ""
|
|
delete(form.Headers, "X-Delete")
|
|
form.Headers["X-New"] = "new"
|
|
form.CustomFields["channel"] = "new"
|
|
delete(form.CustomFields, "delete")
|
|
body, err := json.Marshal(form)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before, err := os.ReadFile(filepath.Join(dir, "webhooks.enc"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tested := httptest.NewRecorder()
|
|
h.TestWebhook(tested, httptest.NewRequest(http.MethodPost, "/api/notifications/webhooks/test", bytes.NewReader(body)))
|
|
if tested.Code != http.StatusOK {
|
|
t.Fatalf("saved form test: %d", tested.Code)
|
|
}
|
|
after, err := os.ReadFile(filepath.Join(dir, "webhooks.enc"))
|
|
if err != nil || !bytes.Equal(before, after) || !reflect.DeepEqual(manager.GetWebhooks()[0], liveBefore) {
|
|
t.Fatal("form Test mutated durable or live configuration")
|
|
}
|
|
mu.Lock()
|
|
got := append([]observation(nil), observed...)
|
|
mu.Unlock()
|
|
if len(got) != 1 || got[0].header.Get("Authorization") != "synthetic-auth" || got[0].header.Get("Content-Type") != "application/json" || got[0].header.Get("X-New") != "new" || got[0].header.Get("X-Delete") != "" || strings.Contains(got[0].payload, maskedWebhookSecret) {
|
|
t.Fatal("real Test did not honour saved identities, explicit edits/removals and JSON default")
|
|
}
|
|
updated := httptest.NewRecorder()
|
|
h.UpdateWebhook(updated, httptest.NewRequest(http.MethodPut, "/api/notifications/webhooks/saved-form", bytes.NewReader(body)))
|
|
if updated.Code != http.StatusOK {
|
|
t.Fatalf("saved form update: %d", updated.Code)
|
|
}
|
|
loaded, err := persistence.LoadWebhooks()
|
|
if err != nil || len(loaded) != 1 {
|
|
t.Fatal("encrypted edit unavailable after reload")
|
|
}
|
|
if loaded[0].Headers["authorization"] != "synthetic-auth" || loaded[0].Headers["X-New"] != "new" || len(loaded[0].Headers) != 2 || loaded[0].CustomFields["token"] != "synthetic-token" || loaded[0].CustomFields["channel"] != "new" || len(loaded[0].CustomFields) != 2 || loaded[0].SigningSecret != "synthetic-signing" {
|
|
t.Fatal("Test and persisted edit resolved different identities")
|
|
}
|
|
for _, response := range []string{listed.Body.String(), updated.Body.String(), tested.Body.String()} {
|
|
for _, secret := range []string{"synthetic-auth", "synthetic-token", "synthetic-signing"} {
|
|
if strings.Contains(response, secret) {
|
|
t.Fatal("configuration response disclosed a synthetic saved secret")
|
|
}
|
|
}
|
|
}
|
|
// The existing ordinary-delivery integration separately covers restart,
|
|
// grouped/un-grouped firing and resolution using the saved destination.
|
|
}
|