package services import ( "context" "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "sync/atomic" "testing" "time" "github.com/Wikid82/charon/backend/internal/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func TestSupportsJSONTemplates(t *testing.T) { tests := []struct { name string providerType string expected bool }{ {"webhook", "webhook", true}, {"discord", "discord", true}, {"slack", "slack", true}, {"gotify", "gotify", true}, {"generic", "generic", true}, {"telegram", "telegram", true}, {"unknown", "unknown", false}, {"WEBHOOK uppercase", "WEBHOOK", true}, {"Discord mixed case", "Discord", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := supportsJSONTemplates(tt.providerType) assert.Equal(t, tt.expected, result, "supportsJSONTemplates(%q) should return %v", tt.providerType, tt.expected) }) } } func TestSendJSONPayload_DiscordIPHostRejected(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Type: "discord", URL: "https://203.0.113.10/api/webhooks/123456/token_abc", Template: "custom", Config: `{"content": {{toJSON .Message}}, "username": "Charon"}`, } data := map[string]any{ "Message": "Test notification", "Title": "Test", "Time": time.Now().Format(time.RFC3339), } err = svc.sendJSONPayload(context.Background(), provider, data) require.Error(t, err) assert.Contains(t, err.Error(), "invalid Discord webhook URL") assert.Contains(t, err.Error(), "IP address hosts are not allowed") } func TestValidateDiscordWebhookURL_AcceptsDiscordHostname(t *testing.T) { err := validateDiscordWebhookURL("https://discord.com/api/webhooks/123456/token_abc?wait=true") assert.NoError(t, err) } func TestValidateDiscordWebhookURL_AcceptsCanaryDiscordHostname(t *testing.T) { err := validateDiscordWebhookURL("https://canary.discord.com/api/webhooks/123456/token_abc") assert.NoError(t, err) } func TestValidateDiscordProviderURL_NonDiscordUnchanged(t *testing.T) { err := validateDiscordProviderURL("webhook", "https://203.0.113.20/hooks/test?x=1#y") assert.NoError(t, err) } func TestSendJSONPayload_UsesStoredHostnameURLWithoutHostMutation(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil) // Mock Discord validation to allow test server URLs origValidateDiscordFunc := validateDiscordProviderURLFunc defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }() validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } var observedURLHost string var observedRequestHost string originalDo := webhookDoRequestFunc defer func() { webhookDoRequestFunc = originalDo }() webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { observedURLHost = req.URL.Host observedRequestHost = req.Host return client.Do(req) } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer server.Close() parsedServerURL, err := url.Parse(server.URL) require.NoError(t, err) parsedServerURL.Host = "localhost:" + parsedServerURL.Port() provider := models.NotificationProvider{ Type: "discord", URL: parsedServerURL.String(), Template: "minimal", } data := map[string]any{ "Message": "Test notification", "Title": "Test", "Time": time.Now().Format(time.RFC3339), } err = svc.sendJSONPayload(context.Background(), provider, data) require.NoError(t, err) assert.Equal(t, "localhost:"+parsedServerURL.Port(), observedURLHost) assert.Equal(t, observedURLHost, observedRequestHost) } func TestSendJSONPayload_Discord(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "POST", r.Method) assert.Equal(t, "application/json", r.Header.Get("Content-Type")) var payload map[string]any err := json.NewDecoder(r.Body).Decode(&payload) require.NoError(t, err) // Discord webhook should have 'content' or 'embeds' assert.True(t, payload["content"] != nil || payload["embeds"] != nil, "Discord payload should have content or embeds") w.WriteHeader(http.StatusOK) })) defer server.Close() // Mock Discord validation to allow test server URL origValidateDiscordFunc := validateDiscordProviderURLFunc defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }() validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Type: "discord", URL: server.URL, Template: "custom", Config: `{"content": {{toJSON .Message}}, "username": "Charon"}`, } data := map[string]any{ "Message": "Test notification", "Title": "Test", "Time": time.Now().Format(time.RFC3339), } err = svc.sendJSONPayload(context.Background(), provider, data) assert.NoError(t, err) } func TestSendJSONPayload_Slack(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var payload map[string]any err := json.NewDecoder(r.Body).Decode(&payload) require.NoError(t, err) // Slack webhook should have 'text' or 'blocks' assert.True(t, payload["text"] != nil || payload["blocks"] != nil, "Slack payload should have text or blocks") w.WriteHeader(http.StatusOK) })) defer server.Close() db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil })) provider := models.NotificationProvider{ Type: "slack", URL: "#test", Token: server.URL, Template: "custom", Config: `{"text": {{toJSON .Message}}}`, } data := map[string]any{ "Message": "Test notification", } err = svc.sendJSONPayload(context.Background(), provider, data) assert.NoError(t, err) } func TestSendJSONPayload_Gotify(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var payload map[string]any err := json.NewDecoder(r.Body).Decode(&payload) require.NoError(t, err) // Gotify webhook should have 'message' assert.NotNil(t, payload["message"], "Gotify payload should have message field") w.WriteHeader(http.StatusOK) })) defer server.Close() db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Type: "gotify", URL: server.URL, Token: "test-token", Template: "custom", Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}}`, } data := map[string]any{ "Message": "Test notification", "Title": "Test", } err = svc.sendJSONPayload(context.Background(), provider, data) assert.NoError(t, err) } func TestSendJSONPayload_TemplateTimeout(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil) // Mock Discord validation to allow private IP check to run origValidateDiscordFunc := validateDiscordProviderURLFunc defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }() validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } // Create a template that would take too long to execute // This is simulated by having a large number of iterations // Use a private IP (10.x) which is blocked by SSRF protection to trigger an error provider := models.NotificationProvider{ Type: "discord", URL: "http://10.0.0.1:9999", Template: "custom", Config: `{"content": {{toJSON .Message}}, "data": {{toJSON .}}}`, } // Create data that will be processed data := map[string]any{ "Message": "Test", } // This should complete quickly, but test the timeout mechanism exists ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() err = svc.sendJSONPayload(ctx, provider, data) // The private IP is blocked by SSRF protection // We're mainly testing that the validation and timeout mechanisms are in place assert.Error(t, err) assert.Contains(t, err.Error(), "private ip addresses is blocked") } func TestSendJSONPayload_TemplateSizeLimit(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil) // Create a template larger than 10KB largeTemplate := strings.Repeat("x", 11*1024) provider := models.NotificationProvider{ Type: "discord", URL: "http://localhost:9999", Template: "custom", Config: largeTemplate, } data := map[string]any{ "Message": "Test", } err = svc.sendJSONPayload(context.Background(), provider, data) assert.Error(t, err) assert.Contains(t, err.Error(), "template size exceeds maximum limit") } func TestSendJSONPayload_DiscordValidation(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Type: "discord", URL: "https://203.0.113.10/api/webhooks/123456/token_abc", Template: "custom", Config: `{"username": "Charon", "message": {{toJSON .Message}}}`, } data := map[string]any{ "Message": "Test", } err = svc.sendJSONPayload(context.Background(), provider, data) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid Discord webhook URL") assert.Contains(t, err.Error(), "IP address hosts are not allowed") } func TestSendJSONPayload_DiscordValidation_MissingMessage(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Type: "discord", URL: "https://discord.com/api/webhooks/123456/token_abc", Template: "custom", Config: `{"username": "Charon"}`, } data := map[string]any{} err = svc.sendJSONPayload(context.Background(), provider, data) assert.Error(t, err) assert.Contains(t, err.Error(), "discord payload requires 'content' or 'embeds'") } func TestSendJSONPayload_SlackValidation(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil) // Slack payload without text or blocks should fail provider := models.NotificationProvider{ Type: "slack", URL: "http://localhost:9999", Template: "custom", Config: `{"username": "Charon"}`, } data := map[string]any{ "Message": "Test", } err = svc.sendJSONPayload(context.Background(), provider, data) assert.Error(t, err) assert.Contains(t, err.Error(), "slack payload requires 'text' or 'blocks'") } func TestSendJSONPayload_GotifyValidation(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil) // Gotify payload without message should fail provider := models.NotificationProvider{ Type: "gotify", URL: "http://localhost:9999", Template: "custom", Config: `{"title": "Test"}`, } data := map[string]any{ "Message": "Test", } err = svc.sendJSONPayload(context.Background(), provider, data) assert.Error(t, err) assert.Contains(t, err.Error(), "gotify payload requires 'message'") } func TestSendJSONPayload_InvalidJSON(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Type: "discord", URL: "http://localhost:9999", Template: "custom", Config: `{invalid json}`, } data := map[string]any{ "Message": "Test", } err = svc.sendJSONPayload(context.Background(), provider, data) assert.Error(t, err) } func TestNormalizeURL_DiscordWebhook_ConvertsToDiscordScheme(t *testing.T) { got := normalizeURL("discord", "https://discord.com/api/webhooks/123/abcDEF_123") assert.Equal(t, "discord://abcDEF_123@123", got) got2 := normalizeURL("discord", "https://discordapp.com/api/webhooks/456/xyz") assert.Equal(t, "discord://xyz@456", got2) } func TestSendExternal_UsesJSONForSupportedServices(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) var called atomic.Bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called.Store(true) var payload map[string]any _ = json.NewDecoder(r.Body).Decode(&payload) assert.NotNil(t, payload["content"]) w.WriteHeader(http.StatusOK) })) defer server.Close() // Mock Discord validation to allow test server URL origValidateDiscordFunc := validateDiscordProviderURLFunc defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }() validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } provider := models.NotificationProvider{ Type: "discord", URL: server.URL, Template: "custom", Config: `{"content": {{toJSON .Message}}}`, Enabled: true, NotifyProxyHosts: true, } db.Create(&provider) svc := NewNotificationService(db, nil) svc.SendExternal(context.Background(), "proxy_host", "Test", "Message", nil) // Give goroutine time to execute time.Sleep(100 * time.Millisecond) assert.True(t, called.Load(), "notification should have been sent via JSON") } func TestTestProvider_UsesJSONForSupportedServices(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var payload map[string]any err := json.NewDecoder(r.Body).Decode(&payload) require.NoError(t, err) assert.NotNil(t, payload["content"]) w.WriteHeader(http.StatusOK) })) defer server.Close() // Mock Discord validation to allow test server URL origValidateDiscordFunc := validateDiscordProviderURLFunc origWebhookDoReq := webhookDoRequestFunc defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc webhookDoRequestFunc = origWebhookDoReq }() validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { return client.Do(req) } db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Type: "discord", URL: server.URL, Template: "custom", Config: `{"content": {{toJSON .Message}}}`, } err = svc.TestProvider(provider) assert.NoError(t, err) } func TestSendJSONPayload_Telegram_ValidPayload(t *testing.T) { var capturedPayload map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { err := json.NewDecoder(r.Body).Decode(&capturedPayload) require.NoError(t, err) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"ok":true}`)) })) defer server.Close() db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil) svc.telegramAPIBaseURL = server.URL provider := models.NotificationProvider{ Type: "telegram", URL: "123456789", Token: "bot-test-token", Template: "custom", Config: `{"text": {{toJSON .Message}}, "title": {{toJSON .Title}}}`, } data := map[string]any{ "Message": "Test notification", "Title": "Test", } sendErr := svc.sendJSONPayload(context.Background(), provider, data) require.NoError(t, sendErr) assert.NotNil(t, capturedPayload["text"], "Telegram payload should have text field") assert.NotNil(t, capturedPayload["chat_id"], "Telegram payload should have chat_id field") } func TestSendJSONPayload_Telegram_AutoMapMessageToText(t *testing.T) { var capturedPayload map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewDecoder(r.Body).Decode(&capturedPayload) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"ok":true}`)) })) defer server.Close() db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil) svc.telegramAPIBaseURL = server.URL provider := models.NotificationProvider{ Type: "telegram", URL: "123456789", Token: "bot-test-token", Template: "custom", Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}}`, } data := map[string]any{ "Message": "Test notification", "Title": "Test", } sendErr := svc.sendJSONPayload(context.Background(), provider, data) // 'message' must be auto-mapped to 'text' — dispatch must succeed. require.NoError(t, sendErr) assert.Equal(t, "Test notification", capturedPayload["text"], "'message' should be auto-mapped to 'text'") } func TestSendJSONPayload_Telegram_MissingTextAndMessage(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Type: "telegram", URL: "123456789", Token: "bot-test-token", Template: "custom", Config: `{"title": {{toJSON .Title}}}`, } data := map[string]any{ "Title": "Test", } sendErr := svc.sendJSONPayload(context.Background(), provider, data) require.Error(t, sendErr) assert.Contains(t, sendErr.Error(), "telegram payload requires 'text' field") } func TestSendJSONPayload_Telegram_SSRFValidation(t *testing.T) { var capturedPath string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedPath = r.URL.Path w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"ok":true}`)) })) defer server.Close() db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil) svc.telegramAPIBaseURL = server.URL // Path traversal in token: Go's net/http transport cleans the URL path, // so "/../../../evil.com/x" does not escape the server host. provider := models.NotificationProvider{ Type: "telegram", URL: "123456789", Token: "test-token/../../../evil.com/x", Template: "custom", Config: `{"text": {{toJSON .Message}}}`, } data := map[string]any{ "Message": "Test", } sendErr := svc.sendJSONPayload(context.Background(), provider, data) // Dispatch must succeed (no validation error) — the path traversal in the // token cannot redirect the request to a different host. The request was // received by our local server, not by evil.com. require.NoError(t, sendErr) // capturedPath is non-empty only if our server handled the request. assert.NotEmpty(t, capturedPath, "request must have been served by the local test server, not redirected to evil.com") } func TestSendJSONPayload_Telegram_401ErrorMessage(t *testing.T) { // Use a webhook provider with a mock server returning 401 to verify // that the dispatch path surfaces "provider returned status 401" in the error. // Telegram cannot be tested this way because its SSRF check requires api.telegram.org. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) })) defer server.Close() db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Type: "webhook", URL: server.URL, Template: "custom", Config: `{"message": {{toJSON .Message}}}`, } data := map[string]any{ "Message": "Test notification", } sendErr := svc.sendJSONPayload(context.Background(), provider, data) require.Error(t, sendErr) assert.Contains(t, sendErr.Error(), "provider returned status 401") }