Files
Charon/backend/internal/services/notification_service_test.go
T
Wikid82 72fd121bdb fix: resolve race conditions and update golangci-lint config
- Fix TestCertificateHandler_Delete race condition:
  - Add WAL mode and busy_timeout to SQLite connection
  - Add sleep to allow background sync goroutine to complete
- Fix TestNotificationService_SendExternal_EdgeCases race condition:
  - Use atomic.Value for cross-goroutine string access
- Update .golangci.yml for version 2:
  - Add version field
  - Move linters-settings under linters.settings
  - Remove deprecated typecheck and gosimple linters
  - Update govet shadow check syntax
2025-11-28 00:54:47 +00:00

575 lines
16 KiB
Go

package services
import (
"encoding/json"
"fmt"
"net/http"
"sync/atomic"
"testing"
"time"
"net/http/httptest"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"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(fmt.Sprintf("%s", notif.ID))
require.NoError(t, err)
var updated models.Notification
db.First(&updated, "id = ?", notif.ID)
assert.True(t, updated.Read)
}
func TestNotificationService_MarkAllAsRead(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc.Create(models.NotificationTypeInfo, "N1", "M1")
svc.Create(models.NotificationTypeInfo, "N2", "M2")
err := svc.MarkAllAsRead()
require.NoError(t, err)
var count int64
db.Model(&models.Notification{}).Where("read = ?", false).Count(&count)
assert.Equal(t, int64(0), count)
}
func TestNotificationService_Providers(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
// Create
provider := models.NotificationProvider{
Name: "Discord",
Type: "discord",
URL: "http://example.com",
}
err := svc.CreateProvider(&provider)
require.NoError(t, err)
assert.NotEmpty(t, provider.ID)
assert.Equal(t, "Discord", provider.Name)
// List
list, err := svc.ListProviders()
require.NoError(t, err)
assert.Len(t, list, 1)
// Update
provider.Name = "Discord Updated"
err = svc.UpdateProvider(&provider)
require.NoError(t, err)
assert.Equal(t, "Discord Updated", provider.Name)
// Delete
err = svc.DeleteProvider(provider.ID)
require.NoError(t, err)
list, err = svc.ListProviders()
require.NoError(t, err)
assert.Len(t, list, 0)
}
func TestNotificationService_TestProvider_Webhook(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
// Start a test server
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]interface{}
json.NewDecoder(r.Body).Decode(&body)
assert.Equal(t, "Test Notification", body["Title"])
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
provider := models.NotificationProvider{
Name: "Test Webhook",
Type: "webhook",
URL: ts.URL,
Config: `{"Title": "{{.Title}}"}`,
}
err := svc.TestProvider(provider)
require.NoError(t, err)
}
func TestNotificationService_SendExternal(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
received := make(chan struct{})
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(received)
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
provider := models.NotificationProvider{
Name: "Test Webhook",
Type: "webhook",
URL: ts.URL,
Enabled: true,
NotifyProxyHosts: true,
}
svc.CreateProvider(&provider)
svc.SendExternal("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_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("proxy_host", "Title", "Message", nil)
select {
case <-received:
t.Fatal("Should not have received webhook")
case <-time.After(100 * time.Millisecond):
// Success (timeout expected)
}
}
func TestNotificationService_SendExternal_Shoutrrr(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
provider := models.NotificationProvider{
Name: "Test Discord",
Type: "discord",
URL: "discord://token@id",
Enabled: true,
NotifyProxyHosts: true,
}
svc.CreateProvider(&provider)
// This will log an error but should cover the code path
svc.SendExternal("proxy_host", "Title", "Message", nil)
// Give it a moment to run goroutine
time.Sleep(100 * time.Millisecond)
}
func TestNormalizeURL(t *testing.T) {
tests := []struct {
name string
serviceType string
rawURL string
expected string
}{
{
name: "Discord HTTPS",
serviceType: "discord",
rawURL: "https://discord.com/api/webhooks/123456789/abcdefg",
expected: "discord://abcdefg@123456789",
},
{
name: "Discord HTTPS with app",
serviceType: "discord",
rawURL: "https://discordapp.com/api/webhooks/123456789/abcdefg",
expected: "discord://abcdefg@123456789",
},
{
name: "Discord Shoutrrr",
serviceType: "discord",
rawURL: "discord://token@id",
expected: "discord://token@id",
},
{
name: "Other Service",
serviceType: "slack",
rawURL: "https://hooks.slack.com/services/...",
expected: "https://hooks.slack.com/services/...",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := normalizeURL(tt.serviceType, tt.rawURL)
assert.Equal(t, tt.expected, result)
})
}
}
func TestNotificationService_SendCustomWebhook_Errors(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
t.Run("invalid URL", func(t *testing.T) {
provider := models.NotificationProvider{
Type: "webhook",
URL: "://invalid-url",
}
data := map[string]interface{}{"Title": "Test", "Message": "Test Message"}
err := svc.sendCustomWebhook(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]interface{}{"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.sendCustomWebhook(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]interface{}{"Title": "Test", "Message": "Test Message"}
err := svc.sendCustomWebhook(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]interface{}
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]interface{}{"Title": "My Title", "Message": "Test Message"}
svc.sendCustomWebhook(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]interface{}
json.NewDecoder(r.Body).Decode(&body)
if content, ok := body["content"]; ok {
receivedContent = content.(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: {"content": "{{.Title}}: {{.Message}}"}
}
data := map[string]interface{}{"Title": "Default Title", "Message": "Test Message"}
svc.sendCustomWebhook(provider, data)
select {
case <-received:
assert.Equal(t, "Default Title: Test Message", receivedContent)
case <-time.After(500 * time.Millisecond):
t.Fatal("Timeout waiting for webhook")
}
})
}
func TestNotificationService_TestProvider_Errors(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
t.Run("unsupported provider type", func(t *testing.T) {
provider := models.NotificationProvider{
Type: "unsupported",
URL: "http://example.com",
}
err := svc.TestProvider(provider)
assert.Error(t, err)
// Shoutrrr returns "unknown service" for unsupported schemes
assert.Contains(t, err.Error(), "unknown service")
})
t.Run("webhook with invalid URL", func(t *testing.T) {
provider := models.NotificationProvider{
Type: "webhook",
URL: "://invalid",
}
err := svc.TestProvider(provider)
assert.Error(t, err)
})
t.Run("discord with invalid URL format", func(t *testing.T) {
provider := models.NotificationProvider{
Type: "discord",
URL: "invalid-discord-url",
}
err := svc.TestProvider(provider)
assert.Error(t, err)
})
t.Run("slack with unreachable webhook", func(t *testing.T) {
provider := models.NotificationProvider{
Type: "slack",
URL: "https://hooks.slack.com/services/INVALID/WEBHOOK/URL",
}
err := svc.TestProvider(provider)
// Shoutrrr will return error for unreachable/invalid webhook
assert.Error(t, err)
})
t.Run("webhook success", func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
provider := models.NotificationProvider{
Type: "webhook",
URL: ts.URL,
}
err := svc.TestProvider(provider)
assert.NoError(t, err)
})
}
func TestNotificationService_SendExternal_EdgeCases(t *testing.T) {
t.Run("no enabled providers", func(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
provider := models.NotificationProvider{
Name: "Disabled",
Type: "webhook",
URL: "http://example.com",
Enabled: false,
}
svc.CreateProvider(&provider)
// Should complete without error
svc.SendExternal("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]interface{}{
"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("proxy_host", "Title", "Message", nil)
svc.SendExternal("uptime", "Title", "Message", nil)
svc.SendExternal("cert", "Title", "Message", nil)
time.Sleep(50 * time.Millisecond)
})
t.Run("custom data passed to webhook", func(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
var receivedCustom atomic.Value
receivedCustom.Store("")
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]interface{}
json.NewDecoder(r.Body).Decode(&body)
if custom, ok := body["custom"]; ok {
receivedCustom.Store(custom.(string))
}
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
provider := models.NotificationProvider{
Name: "Custom Data",
Type: "webhook",
URL: ts.URL,
Enabled: true,
NotifyProxyHosts: true,
Config: `{"custom": "{{.CustomField}}"}`,
}
svc.CreateProvider(&provider)
customData := map[string]interface{}{
"CustomField": "test-value",
}
svc.SendExternal("proxy_host", "Title", "Message", customData)
time.Sleep(100 * time.Millisecond)
assert.Equal(t, "test-value", receivedCustom.Load().(string))
})
}
func TestNotificationService_CreateProvider_Validation(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
t.Run("creates provider with defaults", func(t *testing.T) {
provider := models.NotificationProvider{
Name: "Test",
Type: "webhook",
URL: "http://example.com",
}
err := svc.CreateProvider(&provider)
assert.NoError(t, err)
assert.NotEmpty(t, provider.ID)
assert.False(t, provider.Enabled) // Default
})
t.Run("updates existing provider", func(t *testing.T) {
provider := models.NotificationProvider{
Name: "Original",
Type: "webhook",
URL: "http://example.com",
Enabled: true,
}
err := svc.CreateProvider(&provider)
assert.NoError(t, err)
provider.Name = "Updated"
err = svc.UpdateProvider(&provider)
assert.NoError(t, err)
var updated models.NotificationProvider
db.First(&updated, "id = ?", provider.ID)
assert.Equal(t, "Updated", updated.Name)
})
t.Run("deletes non-existent provider", func(t *testing.T) {
err := svc.DeleteProvider("non-existent-id")
// Should not error on missing provider
assert.NoError(t, err)
})
}