package services import ( "context" "encoding/json" "fmt" "io" "net" "net/http" "net/http/httptest" "sync" "sync/atomic" "testing" "time" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/security" "github.com/Wikid82/charon/backend/internal/trace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func setupNotificationTestDB(t *testing.T) *gorm.DB { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) _ = db.AutoMigrate(&models.Notification{}, &models.NotificationProvider{}) return db } func TestNotificationService_Create(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) notif, err := svc.Create(models.NotificationTypeInfo, "Test", "Message") require.NoError(t, err) assert.Equal(t, "Test", notif.Title) assert.Equal(t, "Message", notif.Message) assert.False(t, notif.Read) } func TestNotificationService_List(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) _, _ = svc.Create(models.NotificationTypeInfo, "N1", "M1") _, _ = svc.Create(models.NotificationTypeInfo, "N2", "M2") list, err := svc.List(false) require.NoError(t, err) assert.Len(t, list, 2) // Mark one as read db.Model(&models.Notification{}).Where("title = ?", "N1").Update("read", true) listUnread, err := svc.List(true) require.NoError(t, err) assert.Len(t, listUnread, 1) assert.Equal(t, "N2", listUnread[0].Title) } func TestNotificationService_MarkAsRead(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) notif, _ := svc.Create(models.NotificationTypeInfo, "N1", "M1") err := svc.MarkAsRead(notif.ID) require.NoError(t, err) var updated models.Notification db.First(&updated, "id = ?", notif.ID) assert.True(t, updated.Read) } func TestNotificationService_MarkAllAsRead(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) _, _ = svc.Create(models.NotificationTypeInfo, "N1", "M1") _, _ = svc.Create(models.NotificationTypeInfo, "N2", "M2") err := svc.MarkAllAsRead() require.NoError(t, err) var count int64 db.Model(&models.Notification{}).Where("read = ?", false).Count(&count) assert.Equal(t, int64(0), count) } func TestNotificationService_Providers(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) // Create provider := models.NotificationProvider{ Name: "Discord", Type: "discord", URL: "http://example.com", } err := svc.CreateProvider(&provider) require.NoError(t, err) assert.NotEmpty(t, provider.ID) assert.Equal(t, "Discord", provider.Name) // List list, err := svc.ListProviders() require.NoError(t, err) assert.Len(t, list, 1) // Update provider.Name = "Discord Updated" err = svc.UpdateProvider(&provider) require.NoError(t, err) assert.Equal(t, "Discord Updated", provider.Name) // Delete err = svc.DeleteProvider(provider.ID) require.NoError(t, err) list, err = svc.ListProviders() require.NoError(t, err) assert.Len(t, list, 0) } func TestNotificationService_TestProvider_Webhook(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) // Start a test server ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) // Minimal template uses lowercase keys: title, message assert.Equal(t, "Test Notification", body["title"]) w.WriteHeader(http.StatusOK) })) defer ts.Close() provider := models.NotificationProvider{ Name: "Test Webhook", Type: "webhook", URL: ts.URL, Template: "minimal", Config: `{"Header": "{{.Title}}"}`, } err := svc.TestProvider(provider) require.NoError(t, err) } func TestNotificationService_SendExternal(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) received := make(chan struct{}) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(received) w.WriteHeader(http.StatusOK) })) defer ts.Close() provider := models.NotificationProvider{ Name: "Test Webhook", Type: "webhook", URL: ts.URL, Enabled: true, NotifyProxyHosts: true, } _ = svc.CreateProvider(&provider) svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", nil) select { case <-received: // Success case <-time.After(1 * time.Second): t.Fatal("Timed out waiting for webhook") } } func TestNotificationService_SendExternal_MinimalVsDetailedTemplates(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) // Minimal template rcvMinimal := make(chan map[string]any, 1) tsMin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) rcvMinimal <- body w.WriteHeader(http.StatusOK) })) defer tsMin.Close() providerMin := models.NotificationProvider{ Name: "Minimal", Type: "webhook", URL: tsMin.URL, Enabled: true, NotifyUptime: true, Template: "minimal", } _ = svc.CreateProvider(&providerMin) data := map[string]any{"Title": "Min Title", "Message": "Min Message", "Time": time.Now().Format(time.RFC3339), "EventType": "uptime"} svc.SendExternal(context.Background(), "uptime", "Min Title", "Min Message", data) select { case body := <-rcvMinimal: // minimal template should contain 'title' and 'message' keys if title, ok := body["title"].(string); ok { assert.Equal(t, "Min Title", title) } else { t.Fatalf("expected title in minimal body") } case <-time.After(500 * time.Millisecond): t.Fatal("Timeout waiting for minimal webhook") } // Detailed template rcvDetailed := make(chan map[string]any, 1) tsDet := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) rcvDetailed <- body w.WriteHeader(http.StatusOK) })) defer tsDet.Close() providerDet := models.NotificationProvider{ Name: "Detailed", Type: "webhook", URL: tsDet.URL, Enabled: true, NotifyUptime: true, Template: "detailed", } _ = svc.CreateProvider(&providerDet) dataDet := map[string]any{"Title": "Det Title", "Message": "Det Message", "Time": time.Now().Format(time.RFC3339), "EventType": "uptime", "HostName": "example-host", "HostIP": "1.2.3.4", "ServiceCount": 1, "Services": []map[string]any{{"Name": "svc1"}}} svc.SendExternal(context.Background(), "uptime", "Det Title", "Det Message", dataDet) select { case body := <-rcvDetailed: // detailed template should contain 'host' and 'services' if host, ok := body["host"].(string); ok { assert.Equal(t, "example-host", host) } else { t.Fatalf("expected host in detailed body") } if _, ok := body["services"]; !ok { t.Fatalf("expected services in detailed body") } case <-time.After(500 * time.Millisecond): t.Fatal("Timeout waiting for detailed webhook") } } func TestNotificationService_SendExternal_Filtered(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) received := make(chan struct{}) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(received) w.WriteHeader(http.StatusOK) })) defer ts.Close() provider := models.NotificationProvider{ Name: "Test Webhook", Type: "webhook", URL: ts.URL, Enabled: true, NotifyProxyHosts: false, // Disabled } _ = svc.CreateProvider(&provider) // Force update to false because GORM default tag might override zero value (false) on Create db.Model(&provider).Update("notify_proxy_hosts", false) svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", nil) select { case <-received: t.Fatal("Should not have received webhook") case <-time.After(100 * time.Millisecond): // Success (timeout expected) } } func TestNotificationService_SendExternal_Shoutrrr(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) provider := models.NotificationProvider{ Name: "Test Discord", Type: "discord", URL: "discord://token@id", Enabled: true, NotifyProxyHosts: true, } _ = svc.CreateProvider(&provider) // This will log an error but should cover the code path svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", nil) // Give it a moment to run goroutine time.Sleep(100 * time.Millisecond) } func TestNormalizeURL(t *testing.T) { tests := []struct { name string serviceType string rawURL string expected string }{ { name: "Discord HTTPS", serviceType: "discord", rawURL: "https://discord.com/api/webhooks/123456789/abcdefg", expected: "discord://abcdefg@123456789", }, { name: "Discord HTTPS with app", serviceType: "discord", rawURL: "https://discordapp.com/api/webhooks/123456789/abcdefg", expected: "discord://abcdefg@123456789", }, { name: "Discord Shoutrrr", serviceType: "discord", rawURL: "discord://token@id", expected: "discord://token@id", }, { name: "Other Service", serviceType: "slack", rawURL: "https://hooks.slack.com/services/...", expected: "https://hooks.slack.com/services/...", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := normalizeURL(tt.serviceType, tt.rawURL) assert.Equal(t, tt.expected, result) }) } } func TestNotificationService_SendCustomWebhook_Errors(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) t.Run("invalid URL", func(t *testing.T) { provider := models.NotificationProvider{ Type: "webhook", URL: "://invalid-url", } data := map[string]any{"Title": "Test", "Message": "Test Message"} err := svc.sendJSONPayload(context.Background(), provider, data) assert.Error(t, err) }) t.Run("unreachable host", func(t *testing.T) { provider := models.NotificationProvider{ Type: "webhook", URL: "http://192.0.2.1:9999", // TEST-NET-1, unreachable } data := map[string]any{"Title": "Test", "Message": "Test Message"} // Set short timeout for client if possible, but here we just expect error // Note: http.Client default timeout is 0 (no timeout), but OS might timeout // We can't easily change client timeout here without modifying service // So we might skip this or just check if it returns error eventually // But for unit test speed, we should probably mock or use a closed port on localhost // Using a closed port on localhost is faster provider.URL = "http://127.0.0.1:54321" // Assuming this port is closed err := svc.sendJSONPayload(context.Background(), provider, data) assert.Error(t, err) }) t.Run("server returns error", func(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer ts.Close() provider := models.NotificationProvider{ Type: "webhook", URL: ts.URL, } data := map[string]any{"Title": "Test", "Message": "Test Message"} err := svc.sendJSONPayload(context.Background(), provider, data) assert.Error(t, err) assert.Contains(t, err.Error(), "500") }) t.Run("valid custom payload template", func(t *testing.T) { receivedBody := "" received := make(chan struct{}) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) if custom, ok := body["custom"]; ok { receivedBody = custom.(string) } w.WriteHeader(http.StatusOK) close(received) })) defer ts.Close() provider := models.NotificationProvider{ Type: "webhook", URL: ts.URL, Config: `{"custom": "Test: {{.Title}}"}`, } data := map[string]any{"Title": "My Title", "Message": "Test Message"} _ = svc.sendJSONPayload(context.Background(), provider, data) select { case <-received: assert.Equal(t, "Test: My Title", receivedBody) case <-time.After(500 * time.Millisecond): t.Fatal("Timeout waiting for webhook") } }) t.Run("default payload without template", func(t *testing.T) { receivedContent := "" received := make(chan struct{}) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) if title, ok := body["title"]; ok { receivedContent = title.(string) } w.WriteHeader(http.StatusOK) close(received) })) defer ts.Close() provider := models.NotificationProvider{ Type: "webhook", URL: ts.URL, // Config is empty, so default template is used: minimal } data := map[string]any{"Title": "Default Title", "Message": "Test Message"} _ = svc.sendJSONPayload(context.Background(), provider, data) select { case <-received: assert.Equal(t, "Default Title", receivedContent) case <-time.After(500 * time.Millisecond): t.Fatal("Timeout waiting for webhook") } }) } func TestNotificationService_SendCustomWebhook_PropagatesRequestID(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) received := make(chan string, 1) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { received <- r.Header.Get("X-Request-ID") w.WriteHeader(http.StatusOK) })) defer ts.Close() provider := models.NotificationProvider{Type: "webhook", URL: ts.URL} data := map[string]any{"Title": "Test", "Message": "Test"} // Build context with requestID value ctx := context.WithValue(context.Background(), trace.RequestIDKey, "my-rid") err := svc.sendJSONPayload(ctx, provider, data) require.NoError(t, err) select { case rid := <-received: assert.Equal(t, "my-rid", rid) case <-time.After(500 * time.Millisecond): t.Fatal("Timed out waiting for webhook request") } } func TestNotificationService_TestProvider_Errors(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) t.Run("unsupported provider type", func(t *testing.T) { provider := models.NotificationProvider{ Type: "unsupported", URL: "http://example.com", } err := svc.TestProvider(provider) assert.Error(t, err) // Shoutrrr returns "unknown service" for unsupported schemes assert.Contains(t, err.Error(), "unknown service") }) t.Run("webhook with invalid URL", func(t *testing.T) { provider := models.NotificationProvider{ Type: "webhook", URL: "://invalid", } err := svc.TestProvider(provider) assert.Error(t, err) }) t.Run("discord with invalid URL format", func(t *testing.T) { provider := models.NotificationProvider{ Type: "discord", URL: "invalid-discord-url", } err := svc.TestProvider(provider) assert.Error(t, err) }) t.Run("slack with unreachable webhook", func(t *testing.T) { provider := models.NotificationProvider{ Type: "slack", URL: "https://hooks.slack.com/services/INVALID/WEBHOOK/URL", } err := svc.TestProvider(provider) // Shoutrrr will return error for unreachable/invalid webhook assert.Error(t, err) }) t.Run("webhook success", func(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer ts.Close() provider := models.NotificationProvider{ Type: "webhook", URL: ts.URL, Template: "minimal", // Use JSON template path which supports HTTP/HTTPS } err := svc.TestProvider(provider) assert.NoError(t, err) }) } func TestSSRF_URLValidation_PrivateIP(t *testing.T) { // Direct IP literal within RFC1918 block should be rejected // Using security.ValidateExternalURL with AllowHTTP option _, err := security.ValidateExternalURL("http://10.0.0.1", security.WithAllowHTTP()) assert.Error(t, err) assert.Contains(t, err.Error(), "private") // Loopback allowed when WithAllowLocalhost is set validatedURL, err := security.ValidateExternalURL("http://127.0.0.1:8080", security.WithAllowHTTP(), security.WithAllowLocalhost(), ) assert.NoError(t, err) assert.Contains(t, validatedURL, "127.0.0.1") // Loopback NOT allowed without WithAllowLocalhost _, err = security.ValidateExternalURL("http://127.0.0.1:8080", security.WithAllowHTTP()) assert.Error(t, err) } func TestSSRF_URLValidation_ComprehensiveBlocking(t *testing.T) { tests := []struct { name string url string shouldBlock bool description string }{ // RFC 1918 private ranges {"10.0.0.0/8", "http://10.0.0.1", true, "Class A private network"}, {"10.255.255.254", "http://10.255.255.254", true, "Class A private high end"}, {"172.16.0.0/12", "http://172.16.0.1", true, "Class B private network start"}, {"172.31.255.254", "http://172.31.255.254", true, "Class B private network end"}, {"192.168.0.0/16", "http://192.168.1.1", true, "Class C private network"}, // Edge cases for 172.x range (16-31 is private, others are not) {"172.15.x (not private)", "http://172.15.0.1", false, "Below private range"}, {"172.32.x (not private)", "http://172.32.0.1", false, "Above private range"}, // Link-local / Cloud metadata {"169.254.169.254", "http://169.254.169.254", true, "AWS/GCP metadata endpoint"}, // Loopback (blocked without WithAllowLocalhost) {"localhost", "http://localhost", true, "Localhost hostname"}, {"127.0.0.1", "http://127.0.0.1", true, "IPv4 loopback"}, {"::1", "http://[::1]", true, "IPv6 loopback"}, // Valid external URLs (should pass) {"google.com", "https://google.com", false, "Public external URL"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Test WITHOUT AllowLocalhost - should block localhost variants _, err := security.ValidateExternalURL(tt.url, security.WithAllowHTTP()) if tt.shouldBlock { assert.Error(t, err, "Expected %s to be blocked: %s", tt.url, tt.description) } else { assert.NoError(t, err, "Expected %s to be allowed: %s", tt.url, tt.description) } }) } } func TestSSRF_WebhookIntegration(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) t.Run("blocks private IP webhook", func(t *testing.T) { provider := models.NotificationProvider{ Type: "webhook", URL: "http://10.0.0.1/webhook", } data := map[string]any{"Title": "Test", "Message": "Test Message"} err := svc.sendJSONPayload(context.Background(), provider, data) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid webhook url") }) t.Run("blocks cloud metadata endpoint", func(t *testing.T) { provider := models.NotificationProvider{ Type: "webhook", URL: "http://169.254.169.254/latest/meta-data/", } data := map[string]any{"Title": "Test", "Message": "Test Message"} err := svc.sendJSONPayload(context.Background(), provider, data) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid webhook url") }) t.Run("allows localhost for testing", func(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer ts.Close() provider := models.NotificationProvider{ Type: "webhook", URL: ts.URL, } data := map[string]any{"Title": "Test", "Message": "Test Message"} err := svc.sendJSONPayload(context.Background(), provider, data) assert.NoError(t, err) }) } func TestNotificationService_SendExternal_EdgeCases(t *testing.T) { t.Run("no enabled providers", func(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) provider := models.NotificationProvider{ Name: "Disabled", Type: "webhook", URL: "http://example.com", Enabled: false, } _ = svc.CreateProvider(&provider) // Should complete without error svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", nil) time.Sleep(50 * time.Millisecond) }) t.Run("provider filtered by category", func(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("Should not call webhook") })) defer ts.Close() provider := models.NotificationProvider{ Name: "Filtered", Type: "webhook", URL: ts.URL, Enabled: true, NotifyProxyHosts: false, NotifyUptime: false, NotifyCerts: false, } // Create provider first (might get defaults) err := db.Create(&provider).Error require.NoError(t, err) // Force update to false using map (to bypass zero value check) err = db.Model(&provider).Updates(map[string]any{ "notify_proxy_hosts": false, "notify_uptime": false, "notify_certs": false, "notify_remote_servers": false, "notify_domains": false, }).Error require.NoError(t, err) // Verify DB state var saved models.NotificationProvider db.First(&saved, "id = ?", provider.ID) require.False(t, saved.NotifyProxyHosts, "NotifyProxyHosts should be false") require.False(t, saved.NotifyUptime, "NotifyUptime should be false") require.False(t, saved.NotifyCerts, "NotifyCerts should be false") svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", nil) svc.SendExternal(context.Background(), "uptime", "Title", "Message", nil) svc.SendExternal(context.Background(), "cert", "Title", "Message", nil) time.Sleep(50 * time.Millisecond) }) t.Run("custom data passed to webhook", func(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) var receivedCustom atomic.Value receivedCustom.Store("") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) if custom, ok := body["custom"]; ok { receivedCustom.Store(custom.(string)) } w.WriteHeader(http.StatusOK) })) defer ts.Close() provider := models.NotificationProvider{ Name: "Custom Data", Type: "webhook", URL: ts.URL, Enabled: true, NotifyProxyHosts: true, Config: `{"custom": "{{.CustomField}}"}`, } _ = svc.CreateProvider(&provider) customData := map[string]any{ "CustomField": "test-value", } svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", customData) time.Sleep(100 * time.Millisecond) assert.Equal(t, "test-value", receivedCustom.Load().(string)) }) } func TestNotificationService_RenderTemplate(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) // Minimal template provider := models.NotificationProvider{Type: "webhook", Template: "minimal"} data := map[string]any{"Title": "T1", "Message": "M1", "Time": time.Now().Format(time.RFC3339), "EventType": "preview"} rendered, parsed, err := svc.RenderTemplate(provider, data) require.NoError(t, err) assert.Contains(t, rendered, "T1") if parsedMap, ok := parsed.(map[string]any); ok { assert.Equal(t, "T1", parsedMap["title"]) } // Invalid custom template returns error provider = models.NotificationProvider{Type: "webhook", Template: "custom", Config: `{"bad": "{{.Title"}`} _, _, err = svc.RenderTemplate(provider, data) assert.Error(t, err) } func TestNotificationService_CreateProvider_Validation(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) t.Run("creates provider with defaults", func(t *testing.T) { provider := models.NotificationProvider{ Name: "Test", Type: "webhook", URL: "http://example.com", } err := svc.CreateProvider(&provider) assert.NoError(t, err) assert.NotEmpty(t, provider.ID) assert.False(t, provider.Enabled) // Default }) t.Run("updates existing provider", func(t *testing.T) { provider := models.NotificationProvider{ Name: "Original", Type: "webhook", URL: "http://example.com", Enabled: true, } err := svc.CreateProvider(&provider) assert.NoError(t, err) provider.Name = "Updated" err = svc.UpdateProvider(&provider) assert.NoError(t, err) var updated models.NotificationProvider db.First(&updated, "id = ?", provider.ID) assert.Equal(t, "Updated", updated.Name) }) t.Run("deletes non-existent provider", func(t *testing.T) { err := svc.DeleteProvider("non-existent-id") // Should not error on missing provider assert.NoError(t, err) }) } func TestNotificationService_IsPrivateIP(t *testing.T) { tests := []struct { name string ipStr string isPrivate bool }{ {"loopback ipv4", "127.0.0.1", true}, {"loopback ipv6", "::1", true}, {"private 10.x", "10.0.0.1", true}, {"private 10.x high", "10.255.255.254", true}, {"private 172.16-31", "172.16.0.1", true}, {"private 172.31", "172.31.255.254", true}, {"private 192.168", "192.168.1.1", true}, {"public 172.32", "172.32.0.1", false}, {"public 172.15", "172.15.0.1", false}, {"public ip", "8.8.8.8", false}, {"public ipv6", "2001:4860:4860::8888", false}, {"link local ipv4", "169.254.1.1", true}, {"link local ipv6", "fe80::1", true}, {"unique local ipv6 fc", "fc00::1", true}, {"unique local ipv6 fc high", "fc12:3456::1", true}, {"unique local ipv6 fd", "fd00::1", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ip := net.ParseIP(tt.ipStr) require.NotNil(t, ip, "failed to parse IP: %s", tt.ipStr) got := isPrivateIP(ip) assert.Equal(t, tt.isPrivate, got, "IP %s private check mismatch", tt.ipStr) }) } } func TestNotificationService_CreateProvider_InvalidCustomTemplate(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) t.Run("invalid custom template on create", func(t *testing.T) { provider := models.NotificationProvider{ Name: "Bad Custom", Type: "webhook", URL: "http://example.com", Template: "custom", Config: `{"bad": "{{.Title"}`, } err := svc.CreateProvider(&provider) assert.Error(t, err) }) t.Run("invalid custom template on update", func(t *testing.T) { provider := models.NotificationProvider{ Name: "Valid", Type: "webhook", URL: "http://example.com", Template: "minimal", } err := svc.CreateProvider(&provider) require.NoError(t, err) provider.Template = "custom" provider.Config = `{"bad": "{{.Title"}` err = svc.UpdateProvider(&provider) assert.Error(t, err) }) } // ============================================ // Phase 2.2: Additional Coverage Tests // ============================================ func TestRenderTemplate_TemplateParseError(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) provider := models.NotificationProvider{ Template: "custom", Config: `{"invalid": {{.Title}`, // Invalid JSON template - missing closing brace } data := map[string]any{ "Title": "Test", "Message": "Test", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } _, _, err := svc.RenderTemplate(provider, data) require.Error(t, err) assert.Contains(t, err.Error(), "parse") } func TestRenderTemplate_TemplateExecutionError(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) provider := models.NotificationProvider{ Template: "custom", Config: `{"title": {{toJSON .Title}}, "broken": {{.NonExistent}}}`, // References missing field without toJSON } data := map[string]any{ "Title": "Test", "Message": "Test", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } rendered, parsed, err := svc.RenderTemplate(provider, data) // Go templates don't error on missing fields, they just render "" // So this should actually succeed but produce invalid JSON require.Error(t, err) assert.Contains(t, err.Error(), "parse rendered template") assert.NotEmpty(t, rendered) assert.Nil(t, parsed) } func TestRenderTemplate_InvalidJSONOutput(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) provider := models.NotificationProvider{ Template: "custom", Config: `{"title": {{.Title}}}`, // Missing toJSON, will produce invalid JSON } data := map[string]any{ "Title": "Test", "Message": "Test", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } rendered, parsed, err := svc.RenderTemplate(provider, data) require.Error(t, err) assert.Contains(t, err.Error(), "parse rendered template") assert.NotEmpty(t, rendered) // Rendered string returned even on validation error assert.Nil(t, parsed) } func TestSendCustomWebhook_HTTPStatusCodeErrors(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) errorCodes := []int{400, 404, 500, 502, 503} for _, statusCode := range errorCodes { t.Run(fmt.Sprintf("status_%d", statusCode), func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(statusCode) })) defer server.Close() provider := models.NotificationProvider{ Type: "webhook", URL: server.URL, Template: "minimal", } data := map[string]any{ "Title": "Test", "Message": "Test", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } err := svc.sendJSONPayload(context.Background(), provider, data) require.Error(t, err) assert.Contains(t, err.Error(), fmt.Sprintf("%d", statusCode)) }) } } func TestSendCustomWebhook_TemplateSelection(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) tests := []struct { name string template string config string expectedKeys []string unexpectedKeys []string }{ { name: "minimal template", template: "minimal", expectedKeys: []string{"title", "message", "time", "event"}, }, { name: "detailed template", template: "detailed", expectedKeys: []string{"title", "message", "time", "event", "host", "host_ip", "service_count", "services"}, }, { name: "custom template", template: "custom", config: `{"custom_key": "custom_value", "title": {{toJSON .Title}}}`, expectedKeys: []string{"custom_key", "title"}, }, { name: "empty template defaults to minimal", template: "", expectedKeys: []string{"title", "message", "time", "event"}, }, { name: "unknown template defaults to minimal", template: "unknown", expectedKeys: []string{"title", "message", "time", "event"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) _ = json.Unmarshal(body, &receivedBody) w.WriteHeader(http.StatusOK) })) defer server.Close() provider := models.NotificationProvider{ Type: "webhook", URL: server.URL, Template: tt.template, Config: tt.config, } data := map[string]any{ "Title": "Test Title", "Message": "Test Message", "Time": time.Now().Format(time.RFC3339), "EventType": "test", "HostName": "testhost", "HostIP": "192.168.1.1", "ServiceCount": 3, "Services": []string{"svc1", "svc2"}, } err := svc.sendJSONPayload(context.Background(), provider, data) require.NoError(t, err) for _, key := range tt.expectedKeys { assert.Contains(t, receivedBody, key, "Expected key %s in response", key) } for _, key := range tt.unexpectedKeys { assert.NotContains(t, receivedBody, key, "Unexpected key %s in response", key) } }) } } func TestSendCustomWebhook_EmptyCustomTemplateDefaultsToMinimal(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) _ = json.Unmarshal(body, &receivedBody) w.WriteHeader(http.StatusOK) })) defer server.Close() provider := models.NotificationProvider{ Type: "webhook", URL: server.URL, Template: "custom", Config: "", // Empty config should default to minimal } data := map[string]any{ "Title": "Test", "Message": "Message", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } err := svc.sendJSONPayload(context.Background(), provider, data) require.NoError(t, err) // Should use minimal template assert.Equal(t, "Test", receivedBody["title"]) assert.Equal(t, "Message", receivedBody["message"]) } func TestCreateProvider_EmptyCustomTemplateAllowed(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) provider := &models.NotificationProvider{ Name: "empty-template", Type: "webhook", URL: "http://localhost:8080/webhook", Template: "custom", Config: "", // Empty should be allowed and default to minimal } err := svc.CreateProvider(provider) require.NoError(t, err) assert.NotEmpty(t, provider.ID) } func TestUpdateProvider_NonCustomTemplateSkipsValidation(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) provider := &models.NotificationProvider{ Name: "test", Type: "webhook", URL: "http://localhost:8080", Template: "minimal", } require.NoError(t, db.Create(provider).Error) // Update to detailed template (Config can be garbage since it's ignored) provider.Template = "detailed" provider.Config = "this is not JSON but should be ignored" err := svc.UpdateProvider(provider) require.NoError(t, err) // Should succeed because detailed template doesn't use Config } func TestIsPrivateIP_EdgeCases(t *testing.T) { tests := []struct { name string ip string isPrivate bool }{ // Boundary testing for 172.16-31 range {"172.15.255.255 (just before private)", "172.15.255.255", false}, {"172.16.0.0 (start of private)", "172.16.0.0", true}, {"172.31.255.255 (end of private)", "172.31.255.255", true}, {"172.32.0.0 (just after private)", "172.32.0.0", false}, // IPv6 unique local address boundaries {"fbff:ffff:ffff:ffff:ffff:ffff:ffff:ffff (before ULA)", "fbff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", false}, {"fc00::0 (start of ULA)", "fc00::0", true}, {"fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff (end of ULA)", "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", true}, {"fe00::0 (after ULA)", "fe00::0", false}, // IPv6 link-local boundaries {"fe7f:ffff:ffff:ffff:ffff:ffff:ffff:ffff (before link-local)", "fe7f:ffff:ffff:ffff:ffff:ffff:ffff:ffff", false}, {"fe80::0 (start of link-local)", "fe80::0", true}, {"febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff (end of link-local)", "febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff", true}, {"fec0::0 (after link-local)", "fec0::0", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ip := net.ParseIP(tt.ip) require.NotNil(t, ip, "Failed to parse IP: %s", tt.ip) result := isPrivateIP(ip) assert.Equal(t, tt.isPrivate, result, "IP %s: expected private=%v, got=%v", tt.ip, tt.isPrivate, result) }) } } func TestSendCustomWebhook_ContextCancellation(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) // Create a server that delays response server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(500 * time.Millisecond) w.WriteHeader(http.StatusOK) })) defer server.Close() provider := models.NotificationProvider{ Type: "webhook", URL: server.URL, Template: "minimal", } data := map[string]any{ "Title": "Test", "Message": "Test", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } // Create context with immediate cancellation ctx, cancel := context.WithCancel(context.Background()) cancel() err := svc.sendJSONPayload(ctx, provider, data) require.Error(t, err) } func TestSendExternal_UnknownEventTypeSendsToAll(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) var callCount atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount.Add(1) w.WriteHeader(http.StatusOK) })) defer server.Close() provider := models.NotificationProvider{ Name: "all-disabled", Type: "webhook", URL: server.URL, Enabled: true, // All notification types disabled NotifyProxyHosts: false, NotifyRemoteServers: false, NotifyDomains: false, NotifyCerts: false, NotifyUptime: false, } require.NoError(t, db.Create(&provider).Error) // Force update with map to avoid zero value issues require.NoError(t, db.Model(&provider).Updates(map[string]any{ "notify_proxy_hosts": false, "notify_remote_servers": false, "notify_domains": false, "notify_certs": false, "notify_uptime": false, }).Error) // Send with unknown event type - should send (default behavior) ctx := context.Background() svc.SendExternal(ctx, "unknown_event_type", "Test", "Message", nil) time.Sleep(100 * time.Millisecond) assert.Greater(t, callCount.Load(), int32(0), "Unknown event type should trigger notification") } func TestCreateProvider_ValidCustomTemplate(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) provider := &models.NotificationProvider{ Name: "valid-custom", Type: "webhook", URL: "http://localhost:8080/webhook", Template: "custom", Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}, "custom_field": "value"}`, } err := svc.CreateProvider(provider) require.NoError(t, err) assert.NotEmpty(t, provider.ID) } func TestUpdateProvider_ValidCustomTemplate(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) provider := &models.NotificationProvider{ Name: "test", Type: "webhook", URL: "http://localhost:8080", Template: "minimal", } require.NoError(t, db.Create(provider).Error) // Update to valid custom template provider.Template = "custom" provider.Config = `{"msg": {{toJSON .Message}}, "title": {{toJSON .Title}}}` err := svc.UpdateProvider(provider) require.NoError(t, err) } func TestRenderTemplate_MinimalAndDetailedTemplates(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) data := map[string]any{ "Title": "Test Title", "Message": "Test Message", "Time": time.Now().Format(time.RFC3339), "EventType": "test", "HostName": "testhost", "HostIP": "192.168.1.1", "ServiceCount": 5, "Services": []string{"web", "api"}, } t.Run("minimal template", func(t *testing.T) { provider := models.NotificationProvider{ Template: "minimal", } rendered, parsed, err := svc.RenderTemplate(provider, data) require.NoError(t, err) require.NotEmpty(t, rendered) require.NotNil(t, parsed) parsedMap := parsed.(map[string]any) assert.Equal(t, "Test Title", parsedMap["title"]) assert.Equal(t, "Test Message", parsedMap["message"]) }) t.Run("detailed template", func(t *testing.T) { provider := models.NotificationProvider{ Template: "detailed", } rendered, parsed, err := svc.RenderTemplate(provider, data) require.NoError(t, err) require.NotEmpty(t, rendered) require.NotNil(t, parsed) parsedMap := parsed.(map[string]any) assert.Equal(t, "Test Title", parsedMap["title"]) assert.Equal(t, "testhost", parsedMap["host"]) assert.Equal(t, "192.168.1.1", parsedMap["host_ip"]) assert.Equal(t, float64(5), parsedMap["service_count"]) }) } // ============================================ // Phase 3: Service-Specific Validation Tests // ============================================ func TestSendJSONPayload_ServiceSpecificValidation(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) t.Run("discord_requires_content_or_embeds", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer server.Close() // Discord without content or embeds should fail provider := models.NotificationProvider{ Type: "discord", URL: server.URL, Template: "custom", Config: `{"message": {{toJSON .Message}}}`, // Missing content/embeds } data := map[string]any{ "Title": "Test", "Message": "Test Message", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } err := svc.sendJSONPayload(context.Background(), provider, data) require.Error(t, err) assert.Contains(t, err.Error(), "discord payload requires 'content' or 'embeds' field") }) t.Run("discord_with_content_succeeds", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer server.Close() provider := models.NotificationProvider{ Type: "discord", URL: server.URL, Template: "custom", Config: `{"content": {{toJSON .Message}}}`, } data := map[string]any{ "Title": "Test", "Message": "Test Message", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } err := svc.sendJSONPayload(context.Background(), provider, data) require.NoError(t, err) }) t.Run("discord_with_embeds_succeeds", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer server.Close() provider := models.NotificationProvider{ Type: "discord", URL: server.URL, Template: "custom", Config: `{"embeds": [{"title": {{toJSON .Title}}}]}`, } data := map[string]any{ "Title": "Test", "Message": "Test Message", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } err := svc.sendJSONPayload(context.Background(), provider, data) require.NoError(t, err) }) t.Run("slack_requires_text_or_blocks", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer server.Close() // Slack without text or blocks should fail provider := models.NotificationProvider{ Type: "slack", URL: server.URL, Template: "custom", Config: `{"message": {{toJSON .Message}}}`, // Missing text/blocks } data := map[string]any{ "Title": "Test", "Message": "Test Message", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } err := svc.sendJSONPayload(context.Background(), provider, data) require.Error(t, err) assert.Contains(t, err.Error(), "slack payload requires 'text' or 'blocks' field") }) t.Run("slack_with_text_succeeds", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer server.Close() provider := models.NotificationProvider{ Type: "slack", URL: server.URL, Template: "custom", Config: `{"text": {{toJSON .Message}}}`, } data := map[string]any{ "Title": "Test", "Message": "Test Message", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } err := svc.sendJSONPayload(context.Background(), provider, data) require.NoError(t, err) }) t.Run("slack_with_blocks_succeeds", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer server.Close() provider := models.NotificationProvider{ Type: "slack", URL: server.URL, Template: "custom", Config: `{"blocks": [{"type": "section", "text": {"type": "mrkdwn", "text": {{toJSON .Message}}}}]}`, } data := map[string]any{ "Title": "Test", "Message": "Test Message", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } err := svc.sendJSONPayload(context.Background(), provider, data) require.NoError(t, err) }) t.Run("gotify_requires_message", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer server.Close() // Gotify without message should fail provider := models.NotificationProvider{ Type: "gotify", URL: server.URL, Template: "custom", Config: `{"title": {{toJSON .Title}}}`, // Missing message } data := map[string]any{ "Title": "Test", "Message": "Test Message", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } err := svc.sendJSONPayload(context.Background(), provider, data) require.Error(t, err) assert.Contains(t, err.Error(), "gotify payload requires 'message' field") }) t.Run("gotify_with_message_succeeds", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer server.Close() provider := models.NotificationProvider{ Type: "gotify", URL: server.URL, Template: "custom", Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}}`, } data := map[string]any{ "Title": "Test", "Message": "Test Message", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } err := svc.sendJSONPayload(context.Background(), provider, data) require.NoError(t, err) }) } // ============================================ // Phase 3: SendExternal Event Type Coverage // ============================================ func TestSendExternal_AllEventTypes(t *testing.T) { eventTypes := []struct { eventType string providerField string }{ {"proxy_host", "NotifyProxyHosts"}, {"remote_server", "NotifyRemoteServers"}, {"domain", "NotifyDomains"}, {"cert", "NotifyCerts"}, {"uptime", "NotifyUptime"}, {"test", ""}, // test always sends {"unknown", ""}, // unknown defaults to true } for _, et := range eventTypes { t.Run(et.eventType, func(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) var callCount atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount.Add(1) w.WriteHeader(http.StatusOK) })) defer server.Close() provider := models.NotificationProvider{ Name: "event-test", Type: "webhook", URL: server.URL, Enabled: true, Template: "minimal", NotifyProxyHosts: et.eventType == "proxy_host", NotifyRemoteServers: et.eventType == "remote_server", NotifyDomains: et.eventType == "domain", NotifyCerts: et.eventType == "cert", NotifyUptime: et.eventType == "uptime", } require.NoError(t, db.Create(&provider).Error) // Update with map to ensure zero values are set properly require.NoError(t, db.Model(&provider).Updates(map[string]any{ "notify_proxy_hosts": et.eventType == "proxy_host", "notify_remote_servers": et.eventType == "remote_server", "notify_domains": et.eventType == "domain", "notify_certs": et.eventType == "cert", "notify_uptime": et.eventType == "uptime", }).Error) svc.SendExternal(context.Background(), et.eventType, "Title", "Message", nil) time.Sleep(100 * time.Millisecond) // test and unknown should always send; others only when their flag is true if et.eventType == "test" || et.eventType == "unknown" { assert.Greater(t, callCount.Load(), int32(0), "Event type %s should trigger notification", et.eventType) } else { assert.Greater(t, callCount.Load(), int32(0), "Event type %s should trigger notification when flag is set", et.eventType) } }) } } // ============================================ // Phase 3: isValidRedirectURL Coverage // ============================================ func TestIsValidRedirectURL(t *testing.T) { tests := []struct { name string url string expected bool }{ {"valid http", "http://example.com/webhook", true}, {"valid https", "https://example.com/webhook", true}, {"invalid scheme ftp", "ftp://example.com", false}, {"invalid scheme file", "file:///etc/passwd", false}, {"no scheme", "example.com/webhook", false}, {"empty hostname", "http:///webhook", false}, {"invalid url", "://invalid", false}, {"javascript scheme", "javascript:alert(1)", false}, {"data scheme", "data:text/html,

test

", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isValidRedirectURL(tt.url) assert.Equal(t, tt.expected, result, "isValidRedirectURL(%q) = %v, want %v", tt.url, result, tt.expected) }) } } // ============================================ // Phase 3: SendExternal with Shoutrrr path (non-JSON) // ============================================ func TestSendExternal_ShoutrrrPath(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) // Test shoutrrr path with mocked function var called atomic.Bool var receivedMsg atomic.Value originalFunc := shoutrrrSendFunc shoutrrrSendFunc = func(url, msg string) error { called.Store(true) receivedMsg.Store(msg) return nil } defer func() { shoutrrrSendFunc = originalFunc }() // Provider without template (uses shoutrrr path) provider := models.NotificationProvider{ Name: "shoutrrr-test", Type: "telegram", // telegram doesn't support JSON templates URL: "telegram://token@telegram?chats=123", Enabled: true, NotifyProxyHosts: true, Template: "", // Empty template forces shoutrrr path } require.NoError(t, db.Create(&provider).Error) svc.SendExternal(context.Background(), "proxy_host", "Test Title", "Test Message", nil) time.Sleep(100 * time.Millisecond) assert.True(t, called.Load(), "shoutrrr function should have been called") msg := receivedMsg.Load().(string) assert.Contains(t, msg, "Test Title") assert.Contains(t, msg, "Test Message") } func TestSendExternal_ShoutrrrPathWithHTTPValidation(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) var called atomic.Bool originalFunc := shoutrrrSendFunc shoutrrrSendFunc = func(url, msg string) error { called.Store(true) return nil } defer func() { shoutrrrSendFunc = originalFunc }() // Provider with HTTP URL but no template AND unsupported type (triggers SSRF check in shoutrrr path) // Using "pushover" which is not in supportsJSONTemplates list provider := models.NotificationProvider{ Name: "http-shoutrrr", Type: "pushover", // Unsupported JSON template type URL: "http://127.0.0.1:8080/webhook", Enabled: true, NotifyProxyHosts: true, Template: "", // Empty template } require.NoError(t, db.Create(&provider).Error) svc.SendExternal(context.Background(), "proxy_host", "Test", "Message", nil) time.Sleep(100 * time.Millisecond) // Should call shoutrrr since URL is valid (localhost allowed) assert.True(t, called.Load()) } func TestSendExternal_ShoutrrrPathBlocksPrivateIP(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) var called atomic.Bool originalFunc := shoutrrrSendFunc shoutrrrSendFunc = func(url, msg string) error { called.Store(true) return nil } defer func() { shoutrrrSendFunc = originalFunc }() // Provider with private IP URL (should be blocked) // Using "pushover" which doesn't support JSON templates provider := models.NotificationProvider{ Name: "private-ip", Type: "pushover", // Unsupported JSON template type URL: "http://10.0.0.1:8080/webhook", Enabled: true, NotifyProxyHosts: true, Template: "", // Empty template } require.NoError(t, db.Create(&provider).Error) svc.SendExternal(context.Background(), "proxy_host", "Test", "Message", nil) time.Sleep(100 * time.Millisecond) // Should NOT call shoutrrr since URL is blocked (private IP) assert.False(t, called.Load(), "shoutrrr should not be called for private IP") } func TestSendExternal_ShoutrrrError(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) // Mock shoutrrr to return error var wg sync.WaitGroup originalFunc := shoutrrrSendFunc shoutrrrSendFunc = func(url, msg string) error { defer wg.Done() return fmt.Errorf("shoutrrr error: connection failed") } defer func() { shoutrrrSendFunc = originalFunc }() provider := models.NotificationProvider{ Name: "error-test", Type: "telegram", URL: "telegram://token@telegram?chats=123", Enabled: true, NotifyProxyHosts: true, Template: "", } require.NoError(t, db.Create(&provider).Error) // Should not panic, just log error wg.Add(1) svc.SendExternal(context.Background(), "proxy_host", "Test", "Message", nil) wg.Wait() } func TestTestProvider_ShoutrrrPath(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) var called atomic.Bool originalFunc := shoutrrrSendFunc shoutrrrSendFunc = func(url, msg string) error { called.Store(true) return nil } defer func() { shoutrrrSendFunc = originalFunc }() // Provider without template uses shoutrrr path provider := models.NotificationProvider{ Type: "telegram", URL: "telegram://token@telegram?chats=123", Template: "", // Empty template } err := svc.TestProvider(provider) require.NoError(t, err) assert.True(t, called.Load()) } func TestTestProvider_HTTPURLValidation(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) t.Run("blocks private IP", func(t *testing.T) { provider := models.NotificationProvider{ Type: "generic", URL: "http://10.0.0.1:8080/webhook", Template: "", // Empty template uses shoutrrr path } err := svc.TestProvider(provider) require.Error(t, err) assert.Contains(t, err.Error(), "invalid notification URL") }) t.Run("allows localhost", func(t *testing.T) { var called atomic.Bool originalFunc := shoutrrrSendFunc shoutrrrSendFunc = func(url, msg string) error { called.Store(true) return nil } defer func() { shoutrrrSendFunc = originalFunc }() provider := models.NotificationProvider{ Type: "generic", URL: "http://127.0.0.1:8080/webhook", Template: "", // Empty template } err := svc.TestProvider(provider) require.NoError(t, err) assert.True(t, called.Load()) }) } // ============================================ // Phase 4: Additional Edge Case Coverage // ============================================ func TestSendJSONPayload_TemplateExecutionError(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer server.Close() // Template that calls a method on nil should cause execution error provider := models.NotificationProvider{ Type: "webhook", URL: server.URL, Template: "custom", Config: `{"result": {{call .NonExistentFunc}}}`, // This will fail during execution } data := map[string]any{ "Title": "Test", "Message": "Test", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } err := svc.sendJSONPayload(context.Background(), provider, data) require.Error(t, err) // The error could be a parse error or execution error depending on Go version } func TestSendJSONPayload_InvalidJSONFromTemplate(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer server.Close() // Template that produces invalid JSON provider := models.NotificationProvider{ Type: "webhook", URL: server.URL, Template: "custom", Config: `{"title": {{.Title}}}`, // Missing toJSON, will produce unquoted string } data := map[string]any{ "Title": "Test Value", "Message": "Test", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } err := svc.sendJSONPayload(context.Background(), provider, data) require.Error(t, err) assert.Contains(t, err.Error(), "invalid JSON payload") } func TestSendJSONPayload_RequestCreationError(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) // This test verifies request creation doesn't panic on edge cases provider := models.NotificationProvider{ Type: "webhook", URL: "http://localhost:8080/webhook", Template: "minimal", } // Use canceled context to trigger early error ctx, cancel := context.WithCancel(context.Background()) cancel() data := map[string]any{ "Title": "Test", "Message": "Test", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } err := svc.sendJSONPayload(ctx, provider, data) require.Error(t, err) } func TestRenderTemplate_CustomTemplateWithWhitespace(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) // Test template selection with various whitespace tests := []struct { name string template string }{ {"detailed with spaces", " detailed "}, {"minimal with tabs", "\tminimal\t"}, {"custom with newlines", "\ncustom\n"}, {"DETAILED uppercase", "DETAILED"}, {"MiNiMaL mixed case", "MiNiMaL"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { provider := models.NotificationProvider{ Template: tt.template, Config: `{"msg": {{toJSON .Message}}}`, // Only used for custom } data := map[string]any{ "Title": "Test", "Message": "Message", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } rendered, parsed, err := svc.RenderTemplate(provider, data) require.NoError(t, err) require.NotEmpty(t, rendered) require.NotNil(t, parsed) }) } } func TestListTemplates_DBError(t *testing.T) { // Create a DB connection and close it to simulate error db, _ := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) _ = db.AutoMigrate(&models.NotificationTemplate{}) svc := NewNotificationService(db) // Close the underlying connection to force error sqlDB, _ := db.DB() _ = sqlDB.Close() _, err := svc.ListTemplates() require.Error(t, err) } func TestSendExternal_DBFetchError(t *testing.T) { // Create a DB connection and close it to simulate error db, _ := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) _ = db.AutoMigrate(&models.NotificationProvider{}) svc := NewNotificationService(db) // Close the underlying connection to force error sqlDB, _ := db.DB() _ = sqlDB.Close() // Should not panic, just log error and return svc.SendExternal(context.Background(), "test", "Title", "Message", nil) } func TestSendExternal_JSONPayloadError(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) // Create a provider that will fail JSON validation (discord without content/embeds) provider := models.NotificationProvider{ Name: "json-error", Type: "discord", URL: "http://localhost:8080/webhook", Enabled: true, NotifyProxyHosts: true, Template: "custom", Config: `{"invalid": {{toJSON .Message}}}`, // Discord requires content or embeds } require.NoError(t, db.Create(&provider).Error) // Should not panic, just log error svc.SendExternal(context.Background(), "proxy_host", "Test", "Message", nil) time.Sleep(100 * time.Millisecond) } func TestSendJSONPayload_HTTPScheme(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) // Test both HTTP and HTTPS schemes schemes := []string{"http", "https"} for _, scheme := range schemes { t.Run(scheme, func(t *testing.T) { // Create server (note: httptest.Server uses http by default) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer server.Close() provider := models.NotificationProvider{ Type: "webhook", URL: server.URL, // httptest always uses http Template: "minimal", } data := map[string]any{ "Title": "Test", "Message": "Test", "Time": time.Now().Format(time.RFC3339), "EventType": "test", } err := svc.sendJSONPayload(context.Background(), provider, data) require.NoError(t, err) }) } }