Pulse/internal/notifications/security_utils_test.go
rcourtman eb46942a40 test: Add edge case for extractTelegramChatID URL parse error
Test URL with control characters triggers url.Parse error path.

Coverage: 92.9% → 100%
2025-12-02 00:31:50 +00:00

924 lines
20 KiB
Go

package notifications
import (
"net"
"testing"
"time"
)
func TestFormatWebhookDuration(t *testing.T) {
tests := []struct {
name string
duration time.Duration
expected string
}{
// Seconds range (< 1 minute)
{
name: "zero duration",
duration: 0,
expected: "0s",
},
{
name: "one second",
duration: time.Second,
expected: "1s",
},
{
name: "30 seconds",
duration: 30 * time.Second,
expected: "30s",
},
{
name: "59 seconds",
duration: 59 * time.Second,
expected: "59s",
},
{
name: "sub-second rounds down to 0s",
duration: 500 * time.Millisecond,
expected: "0s",
},
// Minutes range (>= 1 minute, < 1 hour)
{
name: "one minute exactly",
duration: time.Minute,
expected: "1m",
},
{
name: "90 seconds shows as 1m",
duration: 90 * time.Second,
expected: "1m",
},
{
name: "30 minutes",
duration: 30 * time.Minute,
expected: "30m",
},
{
name: "59 minutes",
duration: 59 * time.Minute,
expected: "59m",
},
// Hours range (>= 1 hour, < 24 hours)
{
name: "one hour exactly",
duration: time.Hour,
expected: "1h 0m",
},
{
name: "1 hour 30 minutes",
duration: 90 * time.Minute,
expected: "1h 30m",
},
{
name: "12 hours",
duration: 12 * time.Hour,
expected: "12h 0m",
},
{
name: "23 hours 59 minutes",
duration: 23*time.Hour + 59*time.Minute,
expected: "23h 59m",
},
// Days range (>= 24 hours)
{
name: "one day exactly",
duration: 24 * time.Hour,
expected: "1d 0h",
},
{
name: "1.5 days",
duration: 36 * time.Hour,
expected: "1d 12h",
},
{
name: "7 days",
duration: 7 * 24 * time.Hour,
expected: "7d 0h",
},
{
name: "30 days",
duration: 30 * 24 * time.Hour,
expected: "30d 0h",
},
{
name: "30 days 23 hours",
duration: 30*24*time.Hour + 23*time.Hour,
expected: "30d 23h",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatWebhookDuration(tt.duration)
if result != tt.expected {
t.Errorf("formatWebhookDuration(%v) = %q, want %q", tt.duration, result, tt.expected)
}
})
}
}
func TestIsNumeric(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
// Valid numeric strings
{
name: "single digit",
input: "0",
expected: true,
},
{
name: "positive integer",
input: "12345",
expected: true,
},
{
name: "large number",
input: "9876543210",
expected: true,
},
{
name: "all zeros",
input: "0000",
expected: true,
},
// Invalid strings
{
name: "empty string",
input: "",
expected: false,
},
{
name: "negative sign",
input: "-123",
expected: false,
},
{
name: "decimal point",
input: "12.34",
expected: false,
},
{
name: "letters",
input: "abc",
expected: false,
},
{
name: "mixed alphanumeric",
input: "123abc",
expected: false,
},
{
name: "spaces",
input: "123 456",
expected: false,
},
{
name: "leading space",
input: " 123",
expected: false,
},
{
name: "trailing space",
input: "123 ",
expected: false,
},
{
name: "plus sign",
input: "+123",
expected: false,
},
{
name: "underscore",
input: "123_456",
expected: false,
},
{
name: "comma separator",
input: "1,234",
expected: false,
},
{
name: "only whitespace",
input: " ",
expected: false,
},
{
name: "unicode digits",
input: "١٢٣", // Arabic-Indic digits
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isNumeric(tt.input)
if result != tt.expected {
t.Errorf("isNumeric(%q) = %v, want %v", tt.input, result, tt.expected)
}
})
}
}
func TestExtractTelegramChatID(t *testing.T) {
tests := []struct {
name string
webhookURL string
expected string
expectError bool
errorSubstr string
}{
// Valid chat IDs
{
name: "valid positive chat ID",
webhookURL: "https://api.telegram.org/bot123456:ABC/sendMessage?chat_id=12345",
expected: "12345",
expectError: false,
},
{
name: "valid negative chat ID (group chat)",
webhookURL: "https://api.telegram.org/bot123456:ABC/sendMessage?chat_id=-987654321",
expected: "-987654321",
expectError: false,
},
{
name: "chat_id with other params",
webhookURL: "https://api.telegram.org/bot123456:ABC/sendMessage?parse_mode=HTML&chat_id=12345&disable_notification=true",
expected: "12345",
expectError: false,
},
{
name: "large chat ID",
webhookURL: "https://api.telegram.org/bot123456:ABC/sendMessage?chat_id=1234567890123",
expected: "1234567890123",
expectError: false,
},
// Missing chat_id parameter
{
name: "missing chat_id parameter",
webhookURL: "https://api.telegram.org/bot123456:ABC/sendMessage",
expectError: true,
errorSubstr: "missing chat_id parameter",
},
{
name: "empty chat_id value",
webhookURL: "https://api.telegram.org/bot123456:ABC/sendMessage?chat_id=",
expectError: true,
errorSubstr: "chat_id parameter is empty",
},
{
name: "chat_id with no value - detected as missing",
webhookURL: "https://api.telegram.org/bot123456:ABC/sendMessage?chat_id",
expectError: true,
errorSubstr: "missing chat_id parameter", // "chat_id=" not present
},
// Invalid chat_id values
{
name: "non-numeric chat_id",
webhookURL: "https://api.telegram.org/bot123456:ABC/sendMessage?chat_id=abc123",
expectError: true,
errorSubstr: "chat_id must be numeric",
},
{
name: "chat_id with spaces",
webhookURL: "https://api.telegram.org/bot123456:ABC/sendMessage?chat_id=123%20456",
expectError: true,
errorSubstr: "chat_id must be numeric",
},
{
name: "chat_id with special chars",
webhookURL: "https://api.telegram.org/bot123456:ABC/sendMessage?chat_id=@channel",
expectError: true,
errorSubstr: "chat_id must be numeric",
},
{
name: "negative with non-numeric suffix",
webhookURL: "https://api.telegram.org/bot123456:ABC/sendMessage?chat_id=-abc",
expectError: true,
errorSubstr: "chat_id must be numeric",
},
{
name: "just a minus sign",
webhookURL: "https://api.telegram.org/bot123456:ABC/sendMessage?chat_id=-",
expectError: true,
errorSubstr: "chat_id must be numeric",
},
// Invalid URLs
{
name: "invalid URL format",
webhookURL: "not-a-url",
expectError: true,
errorSubstr: "missing chat_id parameter",
},
{
name: "empty URL",
webhookURL: "",
expectError: true,
errorSubstr: "missing chat_id parameter",
},
{
name: "URL with control characters",
webhookURL: "https://api.telegram.org/bot\x00/sendMessage?chat_id=12345",
expectError: true,
errorSubstr: "invalid URL format",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := extractTelegramChatID(tt.webhookURL)
if tt.expectError {
if err == nil {
t.Errorf("extractTelegramChatID(%q) expected error containing %q, got nil",
tt.webhookURL, tt.errorSubstr)
} else if tt.errorSubstr != "" && !contains(err.Error(), tt.errorSubstr) {
t.Errorf("extractTelegramChatID(%q) error = %q, want error containing %q",
tt.webhookURL, err.Error(), tt.errorSubstr)
}
} else {
if err != nil {
t.Errorf("extractTelegramChatID(%q) unexpected error: %v", tt.webhookURL, err)
}
if result != tt.expected {
t.Errorf("extractTelegramChatID(%q) = %q, want %q", tt.webhookURL, result, tt.expected)
}
}
})
}
}
func TestIsPrivateIP(t *testing.T) {
tests := []struct {
name string
ip string
expected bool
}{
// RFC1918 10.0.0.0/8
{
name: "10.0.0.0 private",
ip: "10.0.0.0",
expected: true,
},
{
name: "10.0.0.1 private",
ip: "10.0.0.1",
expected: true,
},
{
name: "10.255.255.255 private",
ip: "10.255.255.255",
expected: true,
},
// RFC1918 172.16.0.0/12
{
name: "172.16.0.1 private",
ip: "172.16.0.1",
expected: true,
},
{
name: "172.31.255.255 private",
ip: "172.31.255.255",
expected: true,
},
{
name: "172.15.0.1 public (just below range)",
ip: "172.15.0.1",
expected: false,
},
{
name: "172.32.0.1 public (just above range)",
ip: "172.32.0.1",
expected: false,
},
// RFC1918 192.168.0.0/16
{
name: "192.168.0.1 private",
ip: "192.168.0.1",
expected: true,
},
{
name: "192.168.255.255 private",
ip: "192.168.255.255",
expected: true,
},
{
name: "192.167.0.1 public (just below range)",
ip: "192.167.0.1",
expected: false,
},
{
name: "192.169.0.1 public (just above range)",
ip: "192.169.0.1",
expected: false,
},
// Loopback 127.0.0.0/8
{
name: "127.0.0.1 loopback",
ip: "127.0.0.1",
expected: true,
},
{
name: "127.0.0.0 loopback",
ip: "127.0.0.0",
expected: true,
},
{
name: "127.255.255.255 loopback",
ip: "127.255.255.255",
expected: true,
},
// Link-local 169.254.0.0/16
{
name: "169.254.0.1 link-local",
ip: "169.254.0.1",
expected: true,
},
{
name: "169.254.255.255 link-local",
ip: "169.254.255.255",
expected: true,
},
// Public IPv4
{
name: "8.8.8.8 public DNS",
ip: "8.8.8.8",
expected: false,
},
{
name: "1.1.1.1 public DNS",
ip: "1.1.1.1",
expected: false,
},
{
name: "93.184.216.34 example.com",
ip: "93.184.216.34",
expected: false,
},
// IPv6 loopback
{
name: "::1 IPv6 loopback",
ip: "::1",
expected: true,
},
// IPv6 link-local
{
name: "fe80::1 IPv6 link-local",
ip: "fe80::1",
expected: true,
},
{
name: "fe80::1234:5678:abcd:ef00 IPv6 link-local",
ip: "fe80::1234:5678:abcd:ef00",
expected: true,
},
// IPv6 unique local (fc00::/7)
{
name: "fc00::1 IPv6 unique local",
ip: "fc00::1",
expected: true,
},
{
name: "fd00::1 IPv6 unique local",
ip: "fd00::1",
expected: true,
},
// Public IPv6
{
name: "2001:4860:4860::8888 Google DNS IPv6",
ip: "2001:4860:4860::8888",
expected: false,
},
{
name: "2606:4700:4700::1111 Cloudflare DNS IPv6",
ip: "2606:4700:4700::1111",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ip := net.ParseIP(tt.ip)
if ip == nil {
t.Fatalf("failed to parse IP %q", tt.ip)
}
result := isPrivateIP(ip)
if result != tt.expected {
t.Errorf("isPrivateIP(%q) = %v, want %v", tt.ip, result, tt.expected)
}
})
}
}
func TestIsNumericIP(t *testing.T) {
// NOTE: isNumericIP uses a simple heuristic that only checks for digits, dots, and colons.
// It intentionally doesn't validate full IPv6 with hex digits (a-f).
// This is acceptable because its purpose is to warn about HTTPS with IP addresses,
// and the common case is IPv4 or simple IPv6 like ::1.
tests := []struct {
name string
host string
expected bool
}{
// Valid IPv4 addresses
{
name: "localhost IPv4",
host: "127.0.0.1",
expected: true,
},
{
name: "public IPv4",
host: "8.8.8.8",
expected: true,
},
{
name: "private IPv4",
host: "192.168.1.1",
expected: true,
},
{
name: "all zeros IPv4",
host: "0.0.0.0",
expected: true,
},
// IPv6 addresses (only numeric ones detected)
{
name: "IPv6 loopback short",
host: "::1",
expected: true,
},
{
name: "IPv6 all zeros",
host: "::",
expected: true,
},
// IPv6 with hex letters - implementation returns false (known limitation)
{
name: "IPv6 full with hex - returns false (heuristic limitation)",
host: "2001:0db8:85a3:0000:0000:8a2e:0370:7334",
expected: false, // contains hex letters
},
{
name: "IPv6 compressed with hex - returns false (heuristic limitation)",
host: "2001:db8::1",
expected: false, // contains hex letters
},
// Hostnames (not numeric)
{
name: "domain name",
host: "example.com",
expected: false,
},
{
name: "localhost hostname",
host: "localhost",
expected: false,
},
{
name: "subdomain",
host: "api.example.com",
expected: false,
},
{
name: "hyphenated hostname",
host: "my-server.local",
expected: false,
},
{
name: "hostname with numbers",
host: "server1.example.com",
expected: false,
},
// Edge cases
{
name: "empty string",
host: "",
expected: false,
},
{
name: "only dots - looks like IP pattern",
host: "...",
expected: true, // contains only dots with dots present
},
{
name: "only colons",
host: ":::",
expected: true, // contains only colons with colons present
},
{
name: "single number - no separator",
host: "123",
expected: false, // no dots or colons
},
{
name: "number with dot",
host: "123.",
expected: true, // contains digit and dot
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isNumericIP(tt.host)
if result != tt.expected {
t.Errorf("isNumericIP(%q) = %v, want %v", tt.host, result, tt.expected)
}
})
}
}
func TestIsEmptyInterface(t *testing.T) {
tests := []struct {
name string
value interface{}
expected bool
}{
// Nil
{
name: "nil value",
value: nil,
expected: true,
},
// Empty strings
{
name: "empty string",
value: "",
expected: true,
},
{
name: "whitespace only",
value: " ",
expected: true,
},
{
name: "tabs and spaces",
value: "\t \t",
expected: true,
},
// Non-empty strings
{
name: "non-empty string",
value: "hello",
expected: false,
},
{
name: "string with leading space",
value: " hello",
expected: false,
},
// Other types (not empty)
{
name: "integer zero",
value: 0,
expected: false,
},
{
name: "integer non-zero",
value: 42,
expected: false,
},
{
name: "boolean false",
value: false,
expected: false,
},
{
name: "boolean true",
value: true,
expected: false,
},
{
name: "empty slice",
value: []string{},
expected: false,
},
{
name: "empty map",
value: map[string]string{},
expected: false,
},
// fmt.Stringer types
{
name: "stringer with empty string",
value: emptyStringer{},
expected: true,
},
{
name: "stringer with whitespace",
value: whitespaceStringer{},
expected: true,
},
{
name: "stringer with content",
value: contentStringer{},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isEmptyInterface(tt.value)
if result != tt.expected {
t.Errorf("isEmptyInterface(%v) = %v, want %v", tt.value, result, tt.expected)
}
})
}
}
// Test helper types for fmt.Stringer interface
type emptyStringer struct{}
func (emptyStringer) String() string { return "" }
type whitespaceStringer struct{}
func (whitespaceStringer) String() string { return " \t " }
type contentStringer struct{}
func (contentStringer) String() string { return "content" }
func TestEnsurePushoverCustomFieldAliases(t *testing.T) {
tests := []struct {
name string
input map[string]interface{}
expected map[string]interface{}
}{
{
name: "nil input returns nil",
input: nil,
expected: nil,
},
{
name: "empty map returns empty",
input: map[string]interface{}{},
expected: map[string]interface{}{},
},
{
name: "token already set, no alias",
input: map[string]interface{}{
"token": "my-token",
},
expected: map[string]interface{}{
"token": "my-token",
},
},
{
name: "user already set, no alias",
input: map[string]interface{}{
"user": "my-user",
},
expected: map[string]interface{}{
"user": "my-user",
},
},
{
name: "app_token aliased to token",
input: map[string]interface{}{
"app_token": "legacy-token",
},
expected: map[string]interface{}{
"app_token": "legacy-token",
"token": "legacy-token",
},
},
{
name: "user_token aliased to user",
input: map[string]interface{}{
"user_token": "legacy-user",
},
expected: map[string]interface{}{
"user_token": "legacy-user",
"user": "legacy-user",
},
},
{
name: "both aliases applied",
input: map[string]interface{}{
"app_token": "legacy-token",
"user_token": "legacy-user",
},
expected: map[string]interface{}{
"app_token": "legacy-token",
"user_token": "legacy-user",
"token": "legacy-token",
"user": "legacy-user",
},
},
{
name: "existing token takes precedence over app_token",
input: map[string]interface{}{
"token": "primary-token",
"app_token": "legacy-token",
},
expected: map[string]interface{}{
"token": "primary-token",
"app_token": "legacy-token",
},
},
{
name: "existing user takes precedence over user_token",
input: map[string]interface{}{
"user": "primary-user",
"user_token": "legacy-user",
},
expected: map[string]interface{}{
"user": "primary-user",
"user_token": "legacy-user",
},
},
{
name: "empty token uses app_token alias",
input: map[string]interface{}{
"token": "",
"app_token": "legacy-token",
},
expected: map[string]interface{}{
"token": "legacy-token",
"app_token": "legacy-token",
},
},
{
name: "whitespace token uses app_token alias",
input: map[string]interface{}{
"token": " ",
"app_token": "legacy-token",
},
expected: map[string]interface{}{
"token": "legacy-token",
"app_token": "legacy-token",
},
},
{
name: "empty legacy values not aliased",
input: map[string]interface{}{
"app_token": "",
"user_token": " ",
},
expected: map[string]interface{}{
"app_token": "",
"user_token": " ",
},
},
{
name: "other fields preserved",
input: map[string]interface{}{
"app_token": "legacy-token",
"priority": 2,
"sound": "pushover",
},
expected: map[string]interface{}{
"app_token": "legacy-token",
"token": "legacy-token",
"priority": 2,
"sound": "pushover",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ensurePushoverCustomFieldAliases(tt.input)
if tt.expected == nil {
if result != nil {
t.Errorf("ensurePushoverCustomFieldAliases() = %v, want nil", result)
}
return
}
if len(result) != len(tt.expected) {
t.Errorf("ensurePushoverCustomFieldAliases() length = %d, want %d", len(result), len(tt.expected))
}
for key, expectedValue := range tt.expected {
actualValue, ok := result[key]
if !ok {
t.Errorf("ensurePushoverCustomFieldAliases() missing key %q", key)
continue
}
if actualValue != expectedValue {
t.Errorf("ensurePushoverCustomFieldAliases()[%q] = %v, want %v", key, actualValue, expectedValue)
}
}
})
}
}
// Helper function for string containment check
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}