diff --git a/internal/notifications/notifications_test.go b/internal/notifications/notifications_test.go index 393e32b11..f0245f58c 100644 --- a/internal/notifications/notifications_test.go +++ b/internal/notifications/notifications_test.go @@ -507,6 +507,122 @@ func TestRenderWebhookURL_InvalidTemplate(t *testing.T) { } } +func TestRenderWebhookURL_ErrorPaths(t *testing.T) { + tests := []struct { + name string + urlTemplate string + data WebhookPayloadData + wantErr string + }{ + { + name: "empty URL template", + urlTemplate: "", + data: WebhookPayloadData{}, + wantErr: "webhook URL cannot be empty", + }, + { + name: "whitespace-only URL template", + urlTemplate: " \t\n ", + data: WebhookPayloadData{}, + wantErr: "webhook URL cannot be empty", + }, + { + name: "invalid template syntax", + urlTemplate: "https://example.com/{{.Unclosed", + data: WebhookPayloadData{}, + wantErr: "invalid webhook URL template", + }, + { + name: "template execution error - undefined function", + urlTemplate: "https://example.com/{{undefined_func .Message}}", + data: WebhookPayloadData{Message: "test"}, + wantErr: "invalid webhook URL template", + }, + { + name: "template produces empty URL", + urlTemplate: "{{if false}}https://example.com{{end}}", + data: WebhookPayloadData{}, + wantErr: "webhook URL template produced empty URL", + }, + { + name: "template renders to missing scheme", + urlTemplate: "{{.Message}}/path", + data: WebhookPayloadData{Message: "example.com"}, + wantErr: "missing scheme or host", + }, + { + name: "template renders to missing host", + urlTemplate: "{{.Message}}://", + data: WebhookPayloadData{Message: "https"}, + wantErr: "missing scheme or host", + }, + { + name: "template renders to relative path", + urlTemplate: "/{{.Message}}/webhook", + data: WebhookPayloadData{Message: "api"}, + wantErr: "missing scheme or host", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := renderWebhookURL(tt.urlTemplate, tt.data) + if err == nil { + t.Fatalf("expected error containing %q, got nil (result: %q)", tt.wantErr, result) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err) + } + }) + } +} + +func TestRenderWebhookURL_SuccessCases(t *testing.T) { + tests := []struct { + name string + urlTemplate string + data WebhookPayloadData + want string + }{ + { + name: "static URL - no template", + urlTemplate: "https://example.com/webhook", + data: WebhookPayloadData{}, + want: "https://example.com/webhook", + }, + { + name: "URL with whitespace trimmed", + urlTemplate: " https://example.com/webhook ", + data: WebhookPayloadData{}, + want: "https://example.com/webhook", + }, + { + name: "URL with template variable in path", + urlTemplate: "https://example.com/{{.ResourceType}}/alert", + data: WebhookPayloadData{ResourceType: "vm"}, + want: "https://example.com/vm/alert", + }, + { + name: "URL with urlquery encoding", + urlTemplate: "https://example.com?msg={{urlquery .Message}}", + data: WebhookPayloadData{Message: "hello world"}, + want: "https://example.com?msg=hello+world", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := renderWebhookURL(tt.urlTemplate, tt.data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != tt.want { + t.Fatalf("expected %q, got %q", tt.want, result) + } + }) + } +} + func TestSendTestNotificationApprise(t *testing.T) { nm := NewNotificationManager("") nm.SetEmailConfig(EmailConfig{Enabled: false}) diff --git a/internal/notifications/webhook_allowlist_test.go b/internal/notifications/webhook_allowlist_test.go index bcf8c3e08..9b8360101 100644 --- a/internal/notifications/webhook_allowlist_test.go +++ b/internal/notifications/webhook_allowlist_test.go @@ -2,64 +2,182 @@ package notifications import ( "net" + "strings" "testing" ) func TestUpdateAllowedPrivateCIDRs(t *testing.T) { - nm := NewNotificationManager("") - tests := []struct { name string cidrs string - wantErr bool + wantErr string // empty string means no error expected }{ + // Success cases { name: "empty string clears allowlist", cidrs: "", - wantErr: false, + wantErr: "", }, { name: "single valid CIDR", cidrs: "192.168.1.0/24", - wantErr: false, + wantErr: "", }, { name: "multiple valid CIDRs", cidrs: "192.168.1.0/24,10.0.0.0/8", - wantErr: false, + wantErr: "", }, { name: "CIDR with spaces", cidrs: "192.168.1.0/24, 10.0.0.0/8", - wantErr: false, + wantErr: "", }, { name: "bare IPv4 address", cidrs: "192.168.1.1", - wantErr: false, + wantErr: "", }, { name: "bare IPv6 address", cidrs: "fe80::1", - wantErr: false, + wantErr: "", }, { - name: "invalid CIDR", + name: "valid IPv6 CIDR", + cidrs: "fe80::/10", + wantErr: "", + }, + { + name: "loopback IPv6", + cidrs: "::1", + wantErr: "", + }, + { + name: "multiple valid CIDRs with mixed IP versions", + cidrs: "192.168.1.0/24, 10.0.0.0/8, fe80::/10", + wantErr: "", + }, + { + name: "mixed bare IPs and CIDRs", + cidrs: "192.168.1.1, 10.0.0.0/8", + wantErr: "", + }, + { + name: "whitespace handling", + cidrs: " 192.168.1.0/24 , 10.0.0.1 ", + wantErr: "", + }, + { + name: "empty entries skipped", + cidrs: "192.168.1.0/24,,10.0.0.1", + wantErr: "", + }, + + // Error cases - invalid IP addresses (bare IPs without CIDR notation) + { + name: "invalid IP address - garbage text", cidrs: "not-a-cidr", - wantErr: true, + wantErr: "invalid IP address: not-a-cidr", }, { - name: "invalid IP address", + name: "invalid IP address - out of range octets", cidrs: "999.999.999.999", - wantErr: true, + wantErr: "invalid IP address: 999.999.999.999", + }, + { + name: "invalid IP address in list", + cidrs: "192.168.1.0/24, invalid, 10.0.0.1", + wantErr: "invalid IP address: invalid", + }, + { + name: "IP with too many octets", + cidrs: "192.168.1.1.1", + wantErr: "invalid IP address: 192.168.1.1.1", + }, + { + name: "IP with negative octet", + cidrs: "192.168.-1.0", + wantErr: "invalid IP address: 192.168.-1.0", + }, + { + name: "IP with octet out of range", + cidrs: "192.168.256.0", + wantErr: "invalid IP address: 192.168.256.0", + }, + + // Error cases - invalid CIDR notation + { + name: "CIDR prefix too large for IPv4", + cidrs: "192.168.1.0/33", + wantErr: "invalid CIDR range 192.168.1.0/33", + }, + { + name: "CIDR prefix too large for IPv6", + cidrs: "fe80::/129", + wantErr: "invalid CIDR range fe80::/129", + }, + { + name: "CIDR prefix way too large", + cidrs: "192.168.1.0/999", + wantErr: "invalid CIDR range 192.168.1.0/999", + }, + { + name: "CIDR with negative prefix", + cidrs: "192.168.1.0/-1", + wantErr: "invalid CIDR range 192.168.1.0/-1", + }, + { + name: "CIDR with non-numeric prefix", + cidrs: "192.168.1.0/abc", + wantErr: "invalid CIDR range 192.168.1.0/abc", + }, + { + name: "CIDR with empty prefix", + cidrs: "192.168.1.0/", + wantErr: "invalid CIDR range 192.168.1.0/", + }, + { + name: "CIDR with floating point prefix", + cidrs: "192.168.1.0/24.5", + wantErr: "invalid CIDR range 192.168.1.0/24.5", + }, + + // Error cases - malformed strings + { + name: "double slash in CIDR", + cidrs: "192.168.1.0//24", + wantErr: "invalid CIDR range 192.168.1.0//24", + }, + { + name: "CIDR with invalid IP part", + cidrs: "999.999.999.999/24", + wantErr: "invalid CIDR range 999.999.999.999/24", + }, + { + name: "valid CIDR followed by invalid CIDR", + cidrs: "192.168.1.0/24, bad/cidr", + wantErr: "invalid CIDR range bad/cidr", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + nm := NewNotificationManager("") + defer nm.Stop() + err := nm.UpdateAllowedPrivateCIDRs(tt.cidrs) - if (err != nil) != tt.wantErr { - t.Errorf("UpdateAllowedPrivateCIDRs() error = %v, wantErr %v", err, tt.wantErr) + + if tt.wantErr == "" { + if err != nil { + t.Errorf("UpdateAllowedPrivateCIDRs() unexpected error = %v", err) + } + } else { + if err == nil { + t.Errorf("UpdateAllowedPrivateCIDRs() expected error containing %q, got nil", tt.wantErr) + } else if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("UpdateAllowedPrivateCIDRs() error = %v, want error containing %q", err, tt.wantErr) + } } }) }