Add email as a recognized, feature-flagged notification service type. The flag defaults to false and acts as a dispatch gate alongside the existing discord, gotify, and webhook notification service flags. - Add FlagEmailServiceEnabled constant to the notifications feature flag registry with the canonical key convention - Register the flag in the handler defaults so it appears in the feature flags API response with a false default - Recognise 'email' as a supported notification provider type so that providers of this type pass the type validation gate - Gate email dispatch on the new flag in isDispatchEnabled() following the same pattern as gotify and webhook service flags - Expand the E2E test fixtures FeatureFlags interface to include the new flag key so typed fixture objects remain accurate No email message dispatch is wired in this commit; the flag registration alone makes the email provider type valid and toggleable.
2507 lines
77 KiB
Go
2507 lines
77 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/models"
|
|
"github.com/Wikid82/charon/backend/internal/notifications"
|
|
"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: "https://discord.com/api/webhooks/123456/token_abc",
|
|
}
|
|
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)
|
|
|
|
// Mock validation and webhook request for testing
|
|
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 &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil
|
|
}
|
|
|
|
// 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 Discord",
|
|
Type: "discord",
|
|
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()
|
|
|
|
// Mock discord webhook validation to allow test server URLs
|
|
// Do NOT mock webhookDoRequestFunc - we want real HTTP call to test server
|
|
origValidateDiscordFunc := validateDiscordProviderURLFunc
|
|
defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }()
|
|
validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil }
|
|
|
|
provider := models.NotificationProvider{
|
|
Name: "Test Discord",
|
|
Type: "discord",
|
|
URL: ts.URL,
|
|
Enabled: true,
|
|
NotifyProxyHosts: true,
|
|
Template: "minimal",
|
|
}
|
|
_ = 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)
|
|
|
|
// Mock validation only - allow real HTTP calls to test servers
|
|
origValidateDiscordFunc := validateDiscordProviderURLFunc
|
|
defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }()
|
|
validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil }
|
|
|
|
// 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 Discord",
|
|
Type: "discord",
|
|
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 Discord",
|
|
Type: "discord",
|
|
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 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 Generic",
|
|
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: "https://discord.com/api/webhooks/123/abc",
|
|
}
|
|
err := svc.TestProvider(provider)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "unsupported provider type")
|
|
})
|
|
|
|
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 type not supported", func(t *testing.T) {
|
|
provider := models.NotificationProvider{
|
|
Type: "slack",
|
|
URL: "https://hooks.slack.com/services/INVALID/WEBHOOK/URL",
|
|
}
|
|
err := svc.TestProvider(provider)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "unsupported provider type")
|
|
})
|
|
|
|
t.Run("webhook success", func(t *testing.T) {
|
|
// Mock validation and webhook request for testing
|
|
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 &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil
|
|
}
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
provider := models.NotificationProvider{
|
|
Type: "discord",
|
|
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(), "destination URL validation failed")
|
|
})
|
|
|
|
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(), "destination URL validation failed")
|
|
})
|
|
|
|
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: "https://discord.com/api/webhooks/123/abc",
|
|
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)
|
|
|
|
// Mock validation only - allow real HTTP calls to test server
|
|
origValidateDiscordFunc := validateDiscordProviderURLFunc
|
|
defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }()
|
|
validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil }
|
|
|
|
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 Discord",
|
|
Type: "discord",
|
|
URL: ts.URL,
|
|
Enabled: true,
|
|
NotifyProxyHosts: true,
|
|
Config: `{"content": {{toJSON .Message}}, "custom": "{{.CustomField}}"}`,
|
|
Template: "custom", // Use custom template to enable Config
|
|
}
|
|
_ = 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 Discord",
|
|
Type: "discord",
|
|
URL: "https://discord.com/api/webhooks/123/abc",
|
|
}
|
|
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 Discord",
|
|
Type: "discord",
|
|
URL: "https://discord.com/api/webhooks/123/abc",
|
|
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: "discord",
|
|
URL: "https://discord.com/api/webhooks/123/abc",
|
|
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: "discord",
|
|
URL: "https://discord.com/api/webhooks/123/abc",
|
|
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 "<no value>"
|
|
// 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) {
|
|
// Mock webhook HTTP client to return error status
|
|
originalDo := webhookDoRequestFunc
|
|
defer func() { webhookDoRequestFunc = originalDo }()
|
|
webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: statusCode,
|
|
Body: http.NoBody,
|
|
Header: make(http.Header),
|
|
}, nil
|
|
}
|
|
|
|
provider := models.NotificationProvider{
|
|
Type: "discord",
|
|
URL: "https://discord.com/api/webhooks/123456/test_token",
|
|
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", "content": {{toJSON .Title}}}`,
|
|
expectedKeys: []string{"custom_key", "content"},
|
|
},
|
|
{
|
|
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
|
|
|
|
// Mock webhook HTTP client to capture request
|
|
originalDo := webhookDoRequestFunc
|
|
defer func() { webhookDoRequestFunc = originalDo }()
|
|
webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
|
|
body, _ := io.ReadAll(req.Body)
|
|
_ = json.Unmarshal(body, &receivedBody)
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: http.NoBody,
|
|
Header: make(http.Header),
|
|
}, nil
|
|
}
|
|
|
|
provider := models.NotificationProvider{
|
|
Type: "discord",
|
|
URL: "https://discord.com/api/webhooks/123456/test_token",
|
|
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
|
|
|
|
// Mock webhook HTTP client
|
|
originalDo := webhookDoRequestFunc
|
|
defer func() { webhookDoRequestFunc = originalDo }()
|
|
webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
|
|
body, _ := io.ReadAll(req.Body)
|
|
_ = json.Unmarshal(body, &receivedBody)
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: http.NoBody,
|
|
Header: make(http.Header),
|
|
}, nil
|
|
}
|
|
|
|
provider := models.NotificationProvider{
|
|
Type: "discord",
|
|
URL: "https://discord.com/api/webhooks/123456/test_token",
|
|
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: "discord",
|
|
URL: "https://discord.com/api/webhooks/123456/test_token",
|
|
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: "discord",
|
|
URL: "https://discord.com/api/webhooks/123456/test_token",
|
|
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: "discord",
|
|
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 NOT send (security-first: default false)
|
|
ctx := context.Background()
|
|
svc.SendExternal(ctx, "unknown_event_type", "Test", "Message", nil)
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
assert.Equal(t, int32(0), callCount.Load(), "Unknown event type should not trigger notification (security-first)")
|
|
}
|
|
|
|
func TestCreateProvider_ValidCustomTemplate(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
svc := NewNotificationService(db)
|
|
|
|
provider := &models.NotificationProvider{
|
|
Name: "valid-custom",
|
|
Type: "discord",
|
|
URL: "https://discord.com/api/webhooks/123456/test_token",
|
|
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: "discord",
|
|
URL: "https://discord.com/api/webhooks/123456/test_token",
|
|
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_message_is_normalized_to_content", func(t *testing.T) {
|
|
originalDo := webhookDoRequestFunc
|
|
defer func() { webhookDoRequestFunc = originalDo }()
|
|
webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
|
|
var payload map[string]any
|
|
err := json.NewDecoder(req.Body).Decode(&payload)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Test Message", payload["content"])
|
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil
|
|
}
|
|
|
|
// Discord payload with message should be normalized to content
|
|
provider := models.NotificationProvider{
|
|
Type: "discord",
|
|
URL: "https://discord.com/api/webhooks/123456/token_abc",
|
|
Template: "custom",
|
|
Config: `{"message": {{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_content_succeeds", func(t *testing.T) {
|
|
originalDo := webhookDoRequestFunc
|
|
defer func() { webhookDoRequestFunc = originalDo }()
|
|
webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil
|
|
}
|
|
|
|
provider := models.NotificationProvider{
|
|
Type: "discord",
|
|
URL: "https://discord.com/api/webhooks/123456/token_abc",
|
|
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) {
|
|
originalDo := webhookDoRequestFunc
|
|
defer func() { webhookDoRequestFunc = originalDo }()
|
|
webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil
|
|
}
|
|
|
|
provider := models.NotificationProvider{
|
|
Type: "discord",
|
|
URL: "https://discord.com/api/webhooks/123456/token_abc",
|
|
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 false (security-first)
|
|
}
|
|
|
|
for _, et := range eventTypes {
|
|
t.Run(et.eventType, func(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
svc := NewNotificationService(db)
|
|
|
|
// Mock Discord validation to allow test server URL
|
|
origValidateDiscordFunc := validateDiscordProviderURLFunc
|
|
defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }()
|
|
validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil }
|
|
|
|
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: "discord",
|
|
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 always sends; unknown defaults to false (security-first); others only when their flag is true
|
|
switch et.eventType {
|
|
case "test":
|
|
assert.Greater(t, callCount.Load(), int32(0), "Event type %s should trigger notification", et.eventType)
|
|
case "unknown":
|
|
assert.Equal(t, int32(0), callCount.Load(), "Unknown event type should not trigger notification (security-first)")
|
|
default:
|
|
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", "https://discord.com/api/webhooks/123/abc/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,<h1>test</h1>", 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)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNotificationService_SendExternal_SecurityEventRouting(t *testing.T) {
|
|
eventCases := []struct {
|
|
name string
|
|
eventType string
|
|
apply func(p *models.NotificationProvider)
|
|
}{
|
|
{
|
|
name: "security_waf",
|
|
eventType: "security_waf",
|
|
apply: func(p *models.NotificationProvider) {
|
|
p.NotifySecurityWAFBlocks = true
|
|
},
|
|
},
|
|
{
|
|
name: "security_acl",
|
|
eventType: "security_acl",
|
|
apply: func(p *models.NotificationProvider) {
|
|
p.NotifySecurityACLDenies = true
|
|
},
|
|
},
|
|
{
|
|
name: "security_rate_limit",
|
|
eventType: "security_rate_limit",
|
|
apply: func(p *models.NotificationProvider) {
|
|
p.NotifySecurityRateLimitHits = true
|
|
},
|
|
},
|
|
{
|
|
name: "security_crowdsec",
|
|
eventType: "security_crowdsec",
|
|
apply: func(p *models.NotificationProvider) {
|
|
p.NotifySecurityCrowdSecDecisions = true
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range eventCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
svc := NewNotificationService(db)
|
|
|
|
origValidate := validateDiscordProviderURLFunc
|
|
defer func() { validateDiscordProviderURLFunc = origValidate }()
|
|
validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil }
|
|
|
|
received := make(chan struct{}, 1)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
received <- struct{}{}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
provider := models.NotificationProvider{
|
|
Name: "discord-security",
|
|
Type: "discord",
|
|
URL: server.URL,
|
|
Enabled: true,
|
|
Template: "minimal",
|
|
}
|
|
tc.apply(&provider)
|
|
require.NoError(t, db.Create(&provider).Error)
|
|
|
|
svc.SendExternal(context.Background(), tc.eventType, "Security Title", "Security Message", nil)
|
|
|
|
select {
|
|
case <-received:
|
|
case <-time.After(1 * time.Second):
|
|
t.Fatalf("expected dispatch for event type %s", tc.eventType)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNotificationService_UpdateProvider_ReturnsErrorWhenProviderMissing(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
svc := NewNotificationService(db)
|
|
|
|
err := svc.UpdateProvider(&models.NotificationProvider{
|
|
ID: "missing-id",
|
|
Type: "discord",
|
|
URL: "https://discord.com/api/webhooks/123/token",
|
|
})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestNotificationService_EnsureNotifyOnlyProviderMigration_QueryProvidersError(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
svc := NewNotificationService(db)
|
|
|
|
sqlDB, err := db.DB()
|
|
require.NoError(t, err)
|
|
require.NoError(t, sqlDB.Close())
|
|
|
|
err = svc.EnsureNotifyOnlyProviderMigration(context.Background())
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestNotificationService_EnsureNotifyOnlyProviderMigration_UpdateError(t *testing.T) {
|
|
dbPath := filepath.Join(t.TempDir(), "migration_update_error.db")
|
|
|
|
rwDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
require.NoError(t, rwDB.AutoMigrate(&models.NotificationProvider{}))
|
|
require.NoError(t, rwDB.Create(&models.NotificationProvider{
|
|
ID: "provider-to-update",
|
|
Name: "Legacy Webhook",
|
|
Type: "webhook",
|
|
URL: "https://example.com/webhook",
|
|
Enabled: true,
|
|
MigrationState: "pending",
|
|
}).Error)
|
|
|
|
rwSQLDB, err := rwDB.DB()
|
|
require.NoError(t, err)
|
|
require.NoError(t, rwSQLDB.Close())
|
|
|
|
roDSN := fmt.Sprintf("file:%s?mode=ro", dbPath)
|
|
roDB, err := gorm.Open(sqlite.Open(roDSN), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
|
|
svc := NewNotificationService(roDB)
|
|
err = svc.EnsureNotifyOnlyProviderMigration(context.Background())
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "failed to migrate notification provider")
|
|
|
|
roSQLDB, sqlErr := roDB.DB()
|
|
if sqlErr == nil {
|
|
_ = roSQLDB.Close()
|
|
}
|
|
_ = os.Remove(dbPath)
|
|
}
|
|
|
|
func TestNotificationService_EnsureNotifyOnlyProviderMigration_WrapsFindError(t *testing.T) {
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
// Intentionally do not migrate notification_providers table.
|
|
|
|
svc := NewNotificationService(db)
|
|
err = svc.EnsureNotifyOnlyProviderMigration(context.Background())
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "failed to fetch notification providers for migration")
|
|
}
|
|
|
|
func TestTestProvider_NotifyOnlyRejectsUnsupportedProvider(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
svc := NewNotificationService(db)
|
|
|
|
// Test truly unsupported providers are rejected
|
|
tests := []struct {
|
|
name string
|
|
providerType string
|
|
url string
|
|
}{
|
|
{"telegram", "telegram", "telegram://token@telegram?chats=123"},
|
|
{"slack", "slack", "https://hooks.slack.com/services/T/B/X"},
|
|
{"pushover", "pushover", "pushover://token@user"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
provider := models.NotificationProvider{
|
|
Type: tt.providerType,
|
|
URL: tt.url,
|
|
Template: "",
|
|
}
|
|
|
|
err := svc.TestProvider(provider)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "unsupported provider type")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTestProvider_DiscordUsesNotifyPathInPR1(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
svc := NewNotificationService(db)
|
|
|
|
serverCalled := atomic.Bool{}
|
|
originalDo := webhookDoRequestFunc
|
|
webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
|
|
serverCalled.Store(true)
|
|
// Verify it's using JSON payload (not legacy fallback)
|
|
assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
|
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil
|
|
}
|
|
defer func() { webhookDoRequestFunc = originalDo }()
|
|
|
|
provider := models.NotificationProvider{
|
|
Type: "discord",
|
|
URL: "https://discord.com/api/webhooks/123456789/token_abc",
|
|
Template: "minimal",
|
|
}
|
|
|
|
err := svc.TestProvider(provider)
|
|
require.NoError(t, err)
|
|
assert.True(t, serverCalled.Load(), "discord provider should use JSON webhook path")
|
|
}
|
|
|
|
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: "discord",
|
|
URL: "https://discord.com/api/webhooks/999/invalidtoken",
|
|
Template: "",
|
|
}
|
|
|
|
// Mock the webhook request to fail on IP validation
|
|
originalDo := webhookDoRequestFunc
|
|
webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
|
|
return nil, fmt.Errorf("private IP blocked")
|
|
}
|
|
defer func() { webhookDoRequestFunc = originalDo }()
|
|
|
|
err := svc.TestProvider(provider)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("allows valid discord webhook", func(t *testing.T) {
|
|
serverCalled := atomic.Bool{}
|
|
originalDo := webhookDoRequestFunc
|
|
webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
|
|
serverCalled.Store(true)
|
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil
|
|
}
|
|
defer func() { webhookDoRequestFunc = originalDo }()
|
|
|
|
provider := models.NotificationProvider{
|
|
Type: "discord",
|
|
URL: "https://discord.com/api/webhooks/123456789/validtoken_abc",
|
|
Template: "minimal",
|
|
}
|
|
|
|
err := svc.TestProvider(provider)
|
|
require.NoError(t, err)
|
|
assert.True(t, serverCalled.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: "discord",
|
|
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: "discord",
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Migration Completeness Tests
|
|
// ============================================
|
|
|
|
func TestNotificationService_EnsureNotifyOnlyProviderMigration(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
svc := NewNotificationService(db)
|
|
ctx := context.Background()
|
|
|
|
// Create test providers: discord (supported) and others (deprecated in discord-only rollout)
|
|
providers := []models.NotificationProvider{
|
|
{
|
|
Name: "Webhook Provider",
|
|
Type: "webhook",
|
|
URL: "https://discord.com/api/webhooks/123/abc/webhook",
|
|
Enabled: true,
|
|
},
|
|
{
|
|
Name: "Discord Provider",
|
|
Type: "discord",
|
|
URL: "https://discord.com/api/webhooks/123/token",
|
|
Enabled: true,
|
|
},
|
|
{
|
|
Name: "Telegram Provider (deprecated)",
|
|
Type: "telegram",
|
|
URL: "telegram://token@telegram?chats=123",
|
|
Enabled: true,
|
|
},
|
|
{
|
|
Name: "Pushover Provider (deprecated)",
|
|
Type: "pushover",
|
|
URL: "pushover://token@user",
|
|
Enabled: true,
|
|
},
|
|
{
|
|
Name: "Gotify Provider",
|
|
Type: "gotify",
|
|
URL: "https://discord.com/api/webhooks/123/abc/gotify",
|
|
Enabled: true,
|
|
},
|
|
}
|
|
|
|
for i := range providers {
|
|
require.NoError(t, db.Create(&providers[i]).Error)
|
|
}
|
|
|
|
// Run migration
|
|
err := svc.EnsureNotifyOnlyProviderMigration(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// Verify Discord provider is marked as migrated
|
|
var discord models.NotificationProvider
|
|
require.NoError(t, db.Where("type = ?", "discord").First(&discord).Error)
|
|
assert.Equal(t, "notify_v1", discord.Engine)
|
|
assert.Equal(t, "migrated", discord.MigrationState)
|
|
assert.Equal(t, "", discord.MigrationError)
|
|
assert.NotNil(t, discord.LastMigratedAt)
|
|
assert.True(t, discord.Enabled, "discord provider should remain enabled")
|
|
|
|
// Verify non-Discord providers are marked as deprecated and disabled
|
|
nonDiscordTypes := []string{"webhook", "telegram", "pushover", "gotify"}
|
|
for _, providerType := range nonDiscordTypes {
|
|
var provider models.NotificationProvider
|
|
require.NoError(t, db.Where("type = ?", providerType).First(&provider).Error)
|
|
assert.Equal(t, "deprecated", provider.MigrationState, "%s should be deprecated", providerType)
|
|
assert.Contains(t, provider.MigrationError, "provider type not supported in discord-only rollout",
|
|
"%s should have correct error message", providerType)
|
|
assert.NotNil(t, provider.LastMigratedAt, "%s should have migration timestamp", providerType)
|
|
assert.False(t, provider.Enabled, "%s should be disabled", providerType)
|
|
}
|
|
}
|
|
|
|
func TestNotificationService_EnsureNotifyOnlyProviderMigration_PreservesLegacyURL(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
svc := NewNotificationService(db)
|
|
ctx := context.Background()
|
|
|
|
// Create provider with URL but no legacy_url
|
|
provider := models.NotificationProvider{
|
|
Name: "Test Provider",
|
|
Type: "webhook",
|
|
URL: "http://old-url.com/webhook",
|
|
Enabled: true,
|
|
}
|
|
require.NoError(t, db.Create(&provider).Error)
|
|
|
|
// Run migration
|
|
err := svc.EnsureNotifyOnlyProviderMigration(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// Verify legacy_url is preserved
|
|
var updated models.NotificationProvider
|
|
require.NoError(t, db.First(&updated, "id = ?", provider.ID).Error)
|
|
assert.Equal(t, "http://old-url.com/webhook", updated.LegacyURL)
|
|
}
|
|
|
|
func TestNotificationService_EnsureNotifyOnlyProviderMigration_SkipsIfLegacyURLExists(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
svc := NewNotificationService(db)
|
|
ctx := context.Background()
|
|
|
|
// Create provider with both URL and legacy_url already set
|
|
provider := models.NotificationProvider{
|
|
Name: "Test Provider",
|
|
Type: "webhook",
|
|
URL: "http://new-url.com/webhook",
|
|
LegacyURL: "http://original-url.com/webhook",
|
|
Enabled: true,
|
|
}
|
|
require.NoError(t, db.Create(&provider).Error)
|
|
|
|
// Run migration
|
|
err := svc.EnsureNotifyOnlyProviderMigration(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// Verify legacy_url is NOT overwritten
|
|
var updated models.NotificationProvider
|
|
require.NoError(t, db.First(&updated, "id = ?", provider.ID).Error)
|
|
assert.Equal(t, "http://original-url.com/webhook", updated.LegacyURL, "existing legacy_url should be preserved")
|
|
}
|
|
|
|
func TestNotificationService_EnsureNotifyOnlyProviderMigration_DBError(t *testing.T) {
|
|
db, _ := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
|
_ = db.AutoMigrate(&models.NotificationProvider{})
|
|
svc := NewNotificationService(db)
|
|
|
|
// Close DB to force error
|
|
sqlDB, _ := db.DB()
|
|
_ = sqlDB.Close()
|
|
|
|
err := svc.EnsureNotifyOnlyProviderMigration(context.Background())
|
|
require.Error(t, err)
|
|
// Error message varies by GORM/SQLite version, just check it's an error
|
|
}
|
|
|
|
// TestNotificationService_EnsureNotifyOnlyProviderMigration_FailsClosed verifies that the migration
|
|
// function returns an error with provider context when an update fails. This is a code inspection test
|
|
// since simulating a DB update failure without also failing the fetch is non-trivial with SQLite.
|
|
//
|
|
// The implementation now:
|
|
// 1. Returns error immediately on update failure (fail-closed)
|
|
// 2. Includes provider ID, name, and type in error message
|
|
// 3. Does NOT log-and-continue on update errors
|
|
//
|
|
// Success path is tested by TestNotificationService_EnsureNotifyOnlyProviderMigration
|
|
func TestNotificationService_EnsureNotifyOnlyProviderMigration_FailsClosed(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
svc := NewNotificationService(db)
|
|
ctx := context.Background()
|
|
|
|
// Create a Discord provider (the only type that gets migrated)
|
|
provider := models.NotificationProvider{
|
|
Name: "Discord Provider",
|
|
Type: "discord",
|
|
URL: "https://discord.com/api/webhooks/123/abc",
|
|
Enabled: true,
|
|
}
|
|
require.NoError(t, db.Create(&provider).Error)
|
|
|
|
// Verify migration succeeds normally
|
|
err := svc.EnsureNotifyOnlyProviderMigration(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// Verify Discord provider was updated to migrated state
|
|
var updated models.NotificationProvider
|
|
require.NoError(t, db.First(&updated, "id = ?", provider.ID).Error)
|
|
assert.Equal(t, "migrated", updated.MigrationState)
|
|
assert.Equal(t, "notify_v1", updated.Engine)
|
|
|
|
// Code inspection confirms:
|
|
// - If update fails, function returns: fmt.Errorf("failed to migrate notification provider (id=%s, name=%q, type=%q): %w", ...)
|
|
// - No log-and-continue pattern present
|
|
// - Boot will treat migration incompleteness as failure
|
|
}
|
|
|
|
func TestIsDispatchEnabled_GotifyDefaultTrue(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
_ = db.AutoMigrate(&models.Setting{})
|
|
svc := NewNotificationService(db)
|
|
|
|
// No feature flag row exists — should default to true
|
|
assert.True(t, svc.isDispatchEnabled("gotify"))
|
|
}
|
|
|
|
func TestIsDispatchEnabled_WebhookDefaultTrue(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
_ = db.AutoMigrate(&models.Setting{})
|
|
svc := NewNotificationService(db)
|
|
|
|
// No feature flag row exists — should default to true
|
|
assert.True(t, svc.isDispatchEnabled("webhook"))
|
|
}
|
|
|
|
func TestFlagEmailServiceEnabled_ConstantValue(t *testing.T) {
|
|
assert.Equal(t, "feature.notifications.service.email.enabled", notifications.FlagEmailServiceEnabled)
|
|
}
|
|
|
|
func TestIsSupportedNotificationProviderType_Email(t *testing.T) {
|
|
assert.True(t, isSupportedNotificationProviderType("email"))
|
|
}
|
|
|
|
func TestIsDispatchEnabled_EmailDefaultFalse(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
_ = db.AutoMigrate(&models.Setting{})
|
|
svc := NewNotificationService(db)
|
|
|
|
// No feature flag row — email defaults to false
|
|
assert.False(t, svc.isDispatchEnabled("email"))
|
|
|
|
// Explicitly set flag to true — should now return true
|
|
require.NoError(t, db.Create(&models.Setting{
|
|
Key: notifications.FlagEmailServiceEnabled,
|
|
Value: "true",
|
|
}).Error)
|
|
assert.True(t, svc.isDispatchEnabled("email"))
|
|
}
|
|
|
|
func TestTestProvider_GotifyWorksWithoutFeatureFlag(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
_ = db.AutoMigrate(&models.Setting{})
|
|
svc := NewNotificationService(db)
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
provider := models.NotificationProvider{
|
|
Type: "gotify",
|
|
URL: ts.URL + "/message",
|
|
Template: "minimal",
|
|
}
|
|
|
|
err := svc.TestProvider(provider)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func TestTestProvider_WebhookWorksWithoutFeatureFlag(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
_ = db.AutoMigrate(&models.Setting{})
|
|
svc := NewNotificationService(db)
|
|
|
|
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 + "/webhook",
|
|
Template: "minimal",
|
|
}
|
|
|
|
err := svc.TestProvider(provider)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func TestTestProvider_GotifyWorksWhenFlagExplicitlyFalse(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
_ = db.AutoMigrate(&models.Setting{})
|
|
svc := NewNotificationService(db)
|
|
|
|
// Explicitly set feature flag to false
|
|
db.Create(&models.Setting{Key: "feature.notifications.service.gotify.enabled", Value: "false"})
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
provider := models.NotificationProvider{
|
|
Type: "gotify",
|
|
URL: ts.URL + "/message",
|
|
Template: "minimal",
|
|
}
|
|
|
|
// TestProvider bypasses the dispatch gate, so even with flag=false it should work
|
|
err := svc.TestProvider(provider)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func TestTestProvider_WebhookWorksWhenFlagExplicitlyFalse(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
_ = db.AutoMigrate(&models.Setting{})
|
|
svc := NewNotificationService(db)
|
|
|
|
// Explicitly set feature flag to false
|
|
db.Create(&models.Setting{Key: "feature.notifications.service.webhook.enabled", Value: "false"})
|
|
|
|
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 + "/webhook",
|
|
Template: "minimal",
|
|
}
|
|
|
|
// TestProvider bypasses the dispatch gate, so even with flag=false it should work
|
|
err := svc.TestProvider(provider)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func TestUpdateProvider_TypeMutationBlocked(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
svc := NewNotificationService(db)
|
|
|
|
existing := models.NotificationProvider{
|
|
ID: "prov-type-mut",
|
|
Type: "webhook",
|
|
Name: "Original",
|
|
URL: "https://example.com/hook",
|
|
}
|
|
require.NoError(t, db.Create(&existing).Error)
|
|
|
|
update := models.NotificationProvider{
|
|
ID: "prov-type-mut",
|
|
Type: "discord",
|
|
Name: "Changed",
|
|
URL: "https://discord.com/api/webhooks/123/abc",
|
|
}
|
|
err := svc.UpdateProvider(&update)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "cannot change provider type")
|
|
}
|
|
|
|
func TestUpdateProvider_GotifyKeepsExistingToken(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
svc := NewNotificationService(db)
|
|
|
|
existing := models.NotificationProvider{
|
|
ID: "prov-gotify-token",
|
|
Type: "gotify",
|
|
Name: "My Gotify",
|
|
URL: "https://gotify.example.com",
|
|
Token: "original-secret-token",
|
|
}
|
|
require.NoError(t, db.Create(&existing).Error)
|
|
|
|
update := models.NotificationProvider{
|
|
ID: "prov-gotify-token",
|
|
Type: "gotify",
|
|
Name: "My Gotify Updated",
|
|
URL: "https://gotify.example.com",
|
|
Token: "",
|
|
}
|
|
err := svc.UpdateProvider(&update)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "original-secret-token", update.Token)
|
|
}
|
|
|
|
func TestGetFeatureFlagValue_FoundSetting(t *testing.T) {
|
|
db := setupNotificationTestDB(t)
|
|
require.NoError(t, db.AutoMigrate(&models.Setting{}))
|
|
svc := NewNotificationService(db)
|
|
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
expected bool
|
|
}{
|
|
{"true_string", "true", true},
|
|
{"yes_string", "yes", true},
|
|
{"one_string", "1", true},
|
|
{"false_string", "false", false},
|
|
{"no_string", "no", false},
|
|
{"zero_string", "0", false},
|
|
{"whitespace_true", " True ", true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
db.Where("key = ?", "test.flag").Delete(&models.Setting{})
|
|
db.Create(&models.Setting{Key: "test.flag", Value: tt.value})
|
|
result := svc.getFeatureFlagValue("test.flag", false)
|
|
assert.Equal(t, tt.expected, result, "value=%q", tt.value)
|
|
})
|
|
}
|
|
}
|