mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
980 lines
35 KiB
Go
980 lines
35 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
)
|
|
|
|
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)
|
|
}
|
|
|
|
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_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("UpdateEmailConfig_SaveError", func(t *testing.T) {
|
|
cfg := notifications.EmailConfig{Enabled: true}
|
|
mockManager.On("GetEmailConfig").Return(notifications.EmailConfig{}).Once()
|
|
mockManager.On("SetEmailConfig", mock.Anything).Return().Once()
|
|
mockPersistence.On("SaveEmailConfig", mock.Anything).Return(fmt.Errorf("save error")).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) // Matches implementation: logs error but returns success
|
|
})
|
|
|
|
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"},
|
|
},
|
|
}
|
|
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"])
|
|
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("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}
|
|
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)
|
|
})
|
|
|
|
t.Run("UpdateAppriseConfig", func(t *testing.T) {
|
|
cfg := notifications.AppriseConfig{Enabled: true}
|
|
mockManager.On("SetAppriseConfig", mock.Anything).Return().Once()
|
|
mockPersistence.On("SaveAppriseConfig", mock.Anything).Return(nil).Once()
|
|
mockManager.On("GetAppriseConfig").Return(cfg).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("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()
|
|
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{webhook}).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("DeleteWebhook", func(t *testing.T) {
|
|
mockManager.On("DeleteWebhook", "wh1").Return(nil).Once()
|
|
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{}).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{}).Once()
|
|
}},
|
|
{"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()
|
|
}},
|
|
{"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()
|
|
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{{ID: "wh1"}}).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{}).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("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()
|
|
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()
|
|
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()
|
|
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")
|
|
assert.Contains(t, w.Body.String(), "\"token\":\"legacy-app\"")
|
|
assert.Contains(t, w.Body.String(), "\"user\":\"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()
|
|
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()
|
|
|
|
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()
|
|
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()
|
|
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("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()
|
|
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{updated}).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()
|
|
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{existing}).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")
|
|
assert.Contains(t, w.Body.String(), "\"token\":\"legacy-app\"")
|
|
assert.Contains(t, w.Body.String(), "\"user\":\"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 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()))
|
|
}
|