diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go index 72decb75..e38527c6 100644 --- a/backend/internal/api/handlers/auth_handler.go +++ b/backend/internal/api/handlers/auth_handler.go @@ -148,13 +148,14 @@ func setSecureCookie(c *gin.Context, name, value string, maxAge int) { domain := "" c.SetSameSite(sameSite) - c.SetCookie( + // secure is intentionally false for local non-HTTPS loopback (development only); always true for external HTTPS requests. + c.SetCookie( // codeql[go/cookie-secure-not-set] name, // name value, // value maxAge, // maxAge in seconds "/", // path domain, // domain (empty = current host) - secure, // secure (always true) + secure, // secure true, // httpOnly (no JS access) ) } diff --git a/backend/internal/api/handlers/certificate_handler_test.go b/backend/internal/api/handlers/certificate_handler_test.go index bd2e1aeb..46d9c905 100644 --- a/backend/internal/api/handlers/certificate_handler_test.go +++ b/backend/internal/api/handlers/certificate_handler_test.go @@ -721,7 +721,7 @@ func TestDeleteCertificate_NotificationRateLimit(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) svc := services.NewCertificateService("/tmp", db) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) mockBackupService := &mockBackupService{ createFunc: func() (string, error) { diff --git a/backend/internal/api/handlers/domain_handler_test.go b/backend/internal/api/handlers/domain_handler_test.go index e4f94f11..eff88c6c 100644 --- a/backend/internal/api/handlers/domain_handler_test.go +++ b/backend/internal/api/handlers/domain_handler_test.go @@ -24,7 +24,7 @@ func setupDomainTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) { require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.Domain{}, &models.Notification{}, &models.NotificationProvider{})) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) h := NewDomainHandler(db, ns) r := gin.New() diff --git a/backend/internal/api/handlers/handlers_test.go b/backend/internal/api/handlers/handlers_test.go index d44498b5..996234a1 100644 --- a/backend/internal/api/handlers/handlers_test.go +++ b/backend/internal/api/handlers/handlers_test.go @@ -50,7 +50,7 @@ func TestRemoteServerHandler_List(t *testing.T) { } db.Create(server) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns) router := gin.New() handler.RegisterRoutes(router.Group("/api/v1")) @@ -74,7 +74,7 @@ func TestRemoteServerHandler_Create(t *testing.T) { gin.SetMode(gin.TestMode) db := setupTestDB(t) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns) router := gin.New() handler.RegisterRoutes(router.Group("/api/v1")) @@ -119,7 +119,7 @@ func TestRemoteServerHandler_TestConnection(t *testing.T) { } db.Create(server) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns) router := gin.New() handler.RegisterRoutes(router.Group("/api/v1")) @@ -154,7 +154,7 @@ func TestRemoteServerHandler_Get(t *testing.T) { } db.Create(server) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns) router := gin.New() handler.RegisterRoutes(router.Group("/api/v1")) @@ -188,7 +188,7 @@ func TestRemoteServerHandler_Update(t *testing.T) { } db.Create(server) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns) router := gin.New() handler.RegisterRoutes(router.Group("/api/v1")) @@ -234,7 +234,7 @@ func TestRemoteServerHandler_Delete(t *testing.T) { } db.Create(server) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns) router := gin.New() handler.RegisterRoutes(router.Group("/api/v1")) @@ -271,7 +271,7 @@ func TestProxyHostHandler_List(t *testing.T) { } db.Create(host) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) handler := handlers.NewProxyHostHandler(db, nil, ns, nil) router := gin.New() handler.RegisterRoutes(router.Group("/api/v1")) @@ -295,7 +295,7 @@ func TestProxyHostHandler_Create(t *testing.T) { gin.SetMode(gin.TestMode) db := setupTestDB(t) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) handler := handlers.NewProxyHostHandler(db, nil, ns, nil) router := gin.New() handler.RegisterRoutes(router.Group("/api/v1")) @@ -343,7 +343,7 @@ func TestProxyHostHandler_PartialUpdate_DoesNotWipeFields(t *testing.T) { } db.Create(original) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) handler := handlers.NewProxyHostHandler(db, nil, ns, nil) router := gin.New() handler.RegisterRoutes(router.Group("/api/v1")) @@ -408,7 +408,7 @@ func TestRemoteServerHandler_Errors(t *testing.T) { gin.SetMode(gin.TestMode) db := setupTestDB(t) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns) router := gin.New() handler.RegisterRoutes(router.Group("/api/v1")) diff --git a/backend/internal/api/handlers/notification_coverage_test.go b/backend/internal/api/handlers/notification_coverage_test.go index 162364dc..4b56cb9e 100644 --- a/backend/internal/api/handlers/notification_coverage_test.go +++ b/backend/internal/api/handlers/notification_coverage_test.go @@ -35,7 +35,7 @@ func setAdminContext(c *gin.Context) { func TestNotificationHandler_List_Error(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationHandler(svc) // Drop the table to cause error @@ -57,7 +57,7 @@ func TestNotificationHandler_List_Error(t *testing.T) { func TestNotificationHandler_List_UnreadOnly(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationHandler(svc) // Create some notifications @@ -77,7 +77,7 @@ func TestNotificationHandler_List_UnreadOnly(t *testing.T) { func TestNotificationHandler_MarkAsRead_Error(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationHandler(svc) // Drop table to cause error @@ -97,7 +97,7 @@ func TestNotificationHandler_MarkAsRead_Error(t *testing.T) { func TestNotificationHandler_MarkAllAsRead_Error(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationHandler(svc) // Drop table to cause error @@ -118,7 +118,7 @@ func TestNotificationHandler_MarkAllAsRead_Error(t *testing.T) { func TestNotificationProviderHandler_List_Error(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) // Drop table to cause error @@ -137,7 +137,7 @@ func TestNotificationProviderHandler_List_Error(t *testing.T) { func TestNotificationProviderHandler_Create_InvalidJSON(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) w := httptest.NewRecorder() @@ -154,7 +154,7 @@ func TestNotificationProviderHandler_Create_InvalidJSON(t *testing.T) { func TestNotificationProviderHandler_Create_DBError(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) // Drop table to cause error @@ -182,7 +182,7 @@ func TestNotificationProviderHandler_Create_DBError(t *testing.T) { func TestNotificationProviderHandler_Create_InvalidTemplate(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) provider := models.NotificationProvider{ @@ -208,7 +208,7 @@ func TestNotificationProviderHandler_Create_InvalidTemplate(t *testing.T) { func TestNotificationProviderHandler_Update_InvalidJSON(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) w := httptest.NewRecorder() @@ -226,7 +226,7 @@ func TestNotificationProviderHandler_Update_InvalidJSON(t *testing.T) { func TestNotificationProviderHandler_Update_InvalidTemplate(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) // Create a provider first @@ -258,7 +258,7 @@ func TestNotificationProviderHandler_Update_InvalidTemplate(t *testing.T) { func TestNotificationProviderHandler_Update_DBError(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) // Drop table to cause error @@ -287,7 +287,7 @@ func TestNotificationProviderHandler_Update_DBError(t *testing.T) { func TestNotificationProviderHandler_Delete_Error(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) // Drop table to cause error @@ -307,7 +307,7 @@ func TestNotificationProviderHandler_Delete_Error(t *testing.T) { func TestNotificationProviderHandler_Test_InvalidJSON(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) w := httptest.NewRecorder() @@ -324,7 +324,7 @@ func TestNotificationProviderHandler_Test_InvalidJSON(t *testing.T) { func TestNotificationProviderHandler_Test_RejectsClientSuppliedGotifyToken(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) payload := map[string]any{ @@ -356,7 +356,7 @@ func TestNotificationProviderHandler_Test_RejectsClientSuppliedGotifyToken(t *te func TestNotificationProviderHandler_Test_RejectsGotifyTokenWithWhitespace(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) payload := map[string]any{ @@ -477,7 +477,7 @@ func TestClassifyProviderTestFailure_TLSHandshakeFailed(t *testing.T) { func TestNotificationProviderHandler_Templates(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) w := httptest.NewRecorder() @@ -495,7 +495,7 @@ func TestNotificationProviderHandler_Templates(t *testing.T) { func TestNotificationProviderHandler_Preview_InvalidJSON(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) w := httptest.NewRecorder() @@ -512,7 +512,7 @@ func TestNotificationProviderHandler_Preview_InvalidJSON(t *testing.T) { func TestNotificationProviderHandler_Preview_WithData(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) payload := map[string]any{ @@ -538,7 +538,7 @@ func TestNotificationProviderHandler_Preview_WithData(t *testing.T) { func TestNotificationProviderHandler_Preview_InvalidTemplate(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) payload := map[string]any{ @@ -563,7 +563,7 @@ func TestNotificationProviderHandler_Preview_InvalidTemplate(t *testing.T) { func TestNotificationTemplateHandler_List_Error(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationTemplateHandler(svc) // Drop table to cause error @@ -582,7 +582,7 @@ func TestNotificationTemplateHandler_List_Error(t *testing.T) { func TestNotificationTemplateHandler_Create_BadJSON(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationTemplateHandler(svc) w := httptest.NewRecorder() @@ -599,7 +599,7 @@ func TestNotificationTemplateHandler_Create_BadJSON(t *testing.T) { func TestNotificationTemplateHandler_Create_DBError(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationTemplateHandler(svc) // Drop table to cause error @@ -625,7 +625,7 @@ func TestNotificationTemplateHandler_Create_DBError(t *testing.T) { func TestNotificationTemplateHandler_Update_BadJSON(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationTemplateHandler(svc) w := httptest.NewRecorder() @@ -643,7 +643,7 @@ func TestNotificationTemplateHandler_Update_BadJSON(t *testing.T) { func TestNotificationTemplateHandler_Update_DBError(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationTemplateHandler(svc) // Drop table to cause error @@ -670,7 +670,7 @@ func TestNotificationTemplateHandler_Update_DBError(t *testing.T) { func TestNotificationTemplateHandler_Delete_Error(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationTemplateHandler(svc) // Drop table to cause error @@ -690,7 +690,7 @@ func TestNotificationTemplateHandler_Delete_Error(t *testing.T) { func TestNotificationTemplateHandler_Preview_BadJSON(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationTemplateHandler(svc) w := httptest.NewRecorder() @@ -707,7 +707,7 @@ func TestNotificationTemplateHandler_Preview_BadJSON(t *testing.T) { func TestNotificationTemplateHandler_Preview_TemplateNotFound(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationTemplateHandler(svc) payload := map[string]any{ @@ -730,7 +730,7 @@ func TestNotificationTemplateHandler_Preview_TemplateNotFound(t *testing.T) { func TestNotificationTemplateHandler_Preview_WithStoredTemplate(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationTemplateHandler(svc) // Create a template @@ -762,7 +762,7 @@ func TestNotificationTemplateHandler_Preview_WithStoredTemplate(t *testing.T) { func TestNotificationTemplateHandler_Preview_InvalidTemplate(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationTemplateHandler(svc) payload := map[string]any{ @@ -784,7 +784,7 @@ func TestNotificationTemplateHandler_Preview_InvalidTemplate(t *testing.T) { func TestNotificationProviderHandler_Preview_TokenWriteOnly(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) payload := map[string]any{ @@ -808,7 +808,7 @@ func TestNotificationProviderHandler_Preview_TokenWriteOnly(t *testing.T) { func TestNotificationProviderHandler_Update_TypeChangeRejected(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) existing := models.NotificationProvider{ @@ -842,7 +842,7 @@ func TestNotificationProviderHandler_Update_TypeChangeRejected(t *testing.T) { func TestNotificationProviderHandler_Test_MissingProviderID(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) payload := map[string]any{ @@ -865,7 +865,7 @@ func TestNotificationProviderHandler_Test_MissingProviderID(t *testing.T) { func TestNotificationProviderHandler_Test_ProviderNotFound(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) payload := map[string]any{ @@ -889,7 +889,7 @@ func TestNotificationProviderHandler_Test_ProviderNotFound(t *testing.T) { func TestNotificationProviderHandler_Test_EmptyProviderURL(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) existing := models.NotificationProvider{ @@ -942,7 +942,7 @@ func TestIsProviderValidationError_Comprehensive(t *testing.T) { func TestNotificationProviderHandler_Update_UnsupportedType(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) existing := models.NotificationProvider{ @@ -975,7 +975,7 @@ func TestNotificationProviderHandler_Update_UnsupportedType(t *testing.T) { func TestNotificationProviderHandler_Update_GotifyKeepsExistingToken(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) existing := models.NotificationProvider{ @@ -1013,7 +1013,7 @@ func TestNotificationProviderHandler_Update_GotifyKeepsExistingToken(t *testing. func TestNotificationProviderHandler_Test_ReadDBError(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationProviderHandler(svc) _ = db.Migrator().DropTable(&models.NotificationProvider{}) diff --git a/backend/internal/api/handlers/notification_handler_test.go b/backend/internal/api/handlers/notification_handler_test.go index 5f693ca4..6328acd5 100644 --- a/backend/internal/api/handlers/notification_handler_test.go +++ b/backend/internal/api/handlers/notification_handler_test.go @@ -36,7 +36,7 @@ func TestNotificationHandler_List(t *testing.T) { db.Create(&models.Notification{Title: "Test 1", Message: "Msg 1", Read: false}) db.Create(&models.Notification{Title: "Test 2", Message: "Msg 2", Read: true}) - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := handlers.NewNotificationHandler(service) router := gin.New() router.GET("/notifications", handler.List) @@ -72,7 +72,7 @@ func TestNotificationHandler_MarkAsRead(t *testing.T) { notif := &models.Notification{Title: "Test 1", Message: "Msg 1", Read: false} db.Create(notif) - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := handlers.NewNotificationHandler(service) router := gin.New() router.POST("/notifications/:id/read", handler.MarkAsRead) @@ -96,7 +96,7 @@ func TestNotificationHandler_MarkAllAsRead(t *testing.T) { db.Create(&models.Notification{Title: "Test 1", Message: "Msg 1", Read: false}) db.Create(&models.Notification{Title: "Test 2", Message: "Msg 2", Read: false}) - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := handlers.NewNotificationHandler(service) router := gin.New() router.POST("/notifications/read-all", handler.MarkAllAsRead) @@ -115,7 +115,7 @@ func TestNotificationHandler_MarkAllAsRead(t *testing.T) { func TestNotificationHandler_MarkAllAsRead_Error(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationTestDB(t) - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := handlers.NewNotificationHandler(service) r := gin.New() @@ -134,7 +134,7 @@ func TestNotificationHandler_MarkAllAsRead_Error(t *testing.T) { func TestNotificationHandler_DBError(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationTestDB(t) - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := handlers.NewNotificationHandler(service) r := gin.New() diff --git a/backend/internal/api/handlers/notification_provider_blocker3_test.go b/backend/internal/api/handlers/notification_provider_blocker3_test.go index 324cb5fc..058d4da2 100644 --- a/backend/internal/api/handlers/notification_provider_blocker3_test.go +++ b/backend/internal/api/handlers/notification_provider_blocker3_test.go @@ -28,7 +28,7 @@ func TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T assert.NoError(t, err) // Create handler - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := NewNotificationProviderHandler(service) // Test cases: provider types with security events enabled @@ -96,7 +96,7 @@ func TestBlocker3_CreateProviderAcceptsDiscordWithSecurityEvents(t *testing.T) { assert.NoError(t, err) // Create handler - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := NewNotificationProviderHandler(service) // Create request payload with Discord provider and security events @@ -144,7 +144,7 @@ func TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents(t *testin assert.NoError(t, err) // Create handler - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := NewNotificationProviderHandler(service) // Create request payload with webhook provider but no security events @@ -200,7 +200,7 @@ func TestBlocker3_UpdateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T assert.NoError(t, db.Create(&existingProvider).Error) // Create handler - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := NewNotificationProviderHandler(service) // Try to update to enable security events (should be rejected) @@ -256,7 +256,7 @@ func TestBlocker3_UpdateProviderAcceptsDiscordWithSecurityEvents(t *testing.T) { assert.NoError(t, db.Create(&existingProvider).Error) // Create handler - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := NewNotificationProviderHandler(service) // Update to enable security events @@ -302,7 +302,7 @@ func TestBlocker3_MultipleSecurityEventsEnforcesDiscordOnly(t *testing.T) { assert.NoError(t, err) // Create handler - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := NewNotificationProviderHandler(service) // Test each security event field individually @@ -359,7 +359,7 @@ func TestBlocker3_UpdateProvider_DatabaseError(t *testing.T) { assert.NoError(t, err) // Create handler - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := NewNotificationProviderHandler(service) // Update payload diff --git a/backend/internal/api/handlers/notification_provider_discord_only_test.go b/backend/internal/api/handlers/notification_provider_discord_only_test.go index 5b911ae8..81b635eb 100644 --- a/backend/internal/api/handlers/notification_provider_discord_only_test.go +++ b/backend/internal/api/handlers/notification_provider_discord_only_test.go @@ -24,7 +24,7 @@ func TestDiscordOnly_CreateRejectsNonDiscord(t *testing.T) { require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Notification{})) - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := NewNotificationProviderHandler(service) testCases := []struct { @@ -83,7 +83,7 @@ func TestDiscordOnly_CreateAcceptsDiscord(t *testing.T) { require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Notification{})) - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := NewNotificationProviderHandler(service) payload := map[string]interface{}{ @@ -129,7 +129,7 @@ func TestDiscordOnly_UpdateRejectsTypeMutation(t *testing.T) { } require.NoError(t, db.Create(&deprecatedProvider).Error) - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := NewNotificationProviderHandler(service) // Try to change type to discord @@ -183,7 +183,7 @@ func TestDiscordOnly_UpdateRejectsEnable(t *testing.T) { } require.NoError(t, db.Create(&deprecatedProvider).Error) - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := NewNotificationProviderHandler(service) // Try to enable the deprecated provider @@ -231,7 +231,7 @@ func TestDiscordOnly_UpdateAllowsDisabledDeprecated(t *testing.T) { } require.NoError(t, db.Create(&deprecatedProvider).Error) - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := NewNotificationProviderHandler(service) // Update name (keeping type and enabled unchanged) @@ -279,7 +279,7 @@ func TestDiscordOnly_UpdateAcceptsDiscord(t *testing.T) { } require.NoError(t, db.Create(&discordProvider).Error) - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := NewNotificationProviderHandler(service) // Update to enable security notifications @@ -327,7 +327,7 @@ func TestDiscordOnly_DeleteAllowsDeprecated(t *testing.T) { } require.NoError(t, db.Create(&deprecatedProvider).Error) - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := NewNotificationProviderHandler(service) w := httptest.NewRecorder() @@ -409,7 +409,7 @@ func TestDiscordOnly_ErrorCodes(t *testing.T) { id := tc.setupFunc(db) - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := NewNotificationProviderHandler(service) req, params := tc.requestFunc(id) diff --git a/backend/internal/api/handlers/notification_provider_handler_test.go b/backend/internal/api/handlers/notification_provider_handler_test.go index 2c0cd86e..b8ee74ec 100644 --- a/backend/internal/api/handlers/notification_provider_handler_test.go +++ b/backend/internal/api/handlers/notification_provider_handler_test.go @@ -23,7 +23,7 @@ func setupNotificationProviderTest(t *testing.T) (*gin.Engine, *gorm.DB) { db := handlers.OpenTestDB(t) require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Notification{})) - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := handlers.NewNotificationProviderHandler(service) r := gin.Default() diff --git a/backend/internal/api/handlers/notification_provider_patch_coverage_test.go b/backend/internal/api/handlers/notification_provider_patch_coverage_test.go index cfac52dc..37be8467 100644 --- a/backend/internal/api/handlers/notification_provider_patch_coverage_test.go +++ b/backend/internal/api/handlers/notification_provider_patch_coverage_test.go @@ -33,7 +33,7 @@ func TestUpdate_BlockTypeMutationForNonDiscord(t *testing.T) { } require.NoError(t, db.Create(existing).Error) - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := NewNotificationProviderHandler(service) gin.SetMode(gin.TestMode) @@ -85,7 +85,7 @@ func TestUpdate_AllowTypeMutationForDiscord(t *testing.T) { } require.NoError(t, db.Create(existing).Error) - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := NewNotificationProviderHandler(service) gin.SetMode(gin.TestMode) diff --git a/backend/internal/api/handlers/notification_template_handler_test.go b/backend/internal/api/handlers/notification_template_handler_test.go index 7f9cd6ce..4a8fac99 100644 --- a/backend/internal/api/handlers/notification_template_handler_test.go +++ b/backend/internal/api/handlers/notification_template_handler_test.go @@ -23,7 +23,7 @@ func TestNotificationTemplateHandler_CRUDAndPreview(t *testing.T) { require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{}, &models.Notification{}, &models.NotificationProvider{})) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationTemplateHandler(svc) r := gin.New() @@ -92,7 +92,7 @@ func TestNotificationTemplateHandler_Create_InvalidJSON(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{})) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationTemplateHandler(svc) r := gin.New() r.Use(func(c *gin.Context) { @@ -113,7 +113,7 @@ func TestNotificationTemplateHandler_Update_InvalidJSON(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{})) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationTemplateHandler(svc) r := gin.New() r.Use(func(c *gin.Context) { @@ -134,7 +134,7 @@ func TestNotificationTemplateHandler_Preview_InvalidJSON(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{})) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationTemplateHandler(svc) r := gin.New() r.Use(func(c *gin.Context) { @@ -155,7 +155,7 @@ func TestNotificationTemplateHandler_AdminRequired(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{})) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationTemplateHandler(svc) r := gin.New() @@ -185,7 +185,7 @@ func TestNotificationTemplateHandler_List_DBError(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{})) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationTemplateHandler(svc) r := gin.New() @@ -205,7 +205,7 @@ func TestNotificationTemplateHandler_WriteOps_DBError(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{})) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationTemplateHandler(svc) r := gin.New() @@ -264,7 +264,7 @@ func TestNotificationTemplateHandler_WriteOps_PermissionErrorResponse(t *testing _ = db.Callback().Delete().Remove(deleteHook) }) - svc := services.NewNotificationService(db) + svc := services.NewNotificationService(db, nil) h := NewNotificationTemplateHandler(svc) r := gin.New() diff --git a/backend/internal/api/handlers/proxy_host_handler_security_headers_test.go b/backend/internal/api/handlers/proxy_host_handler_security_headers_test.go index 19fb2a6f..8a1bd228 100644 --- a/backend/internal/api/handlers/proxy_host_handler_security_headers_test.go +++ b/backend/internal/api/handlers/proxy_host_handler_security_headers_test.go @@ -32,7 +32,7 @@ func setupTestRouterForSecurityHeaders(t *testing.T) (*gin.Engine, *gorm.DB) { &models.NotificationProvider{}, )) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) h := NewProxyHostHandler(db, nil, ns, nil) r := gin.New() api := r.Group("/api/v1") diff --git a/backend/internal/api/handlers/proxy_host_handler_test.go b/backend/internal/api/handlers/proxy_host_handler_test.go index cb2f984f..477f7238 100644 --- a/backend/internal/api/handlers/proxy_host_handler_test.go +++ b/backend/internal/api/handlers/proxy_host_handler_test.go @@ -36,7 +36,7 @@ func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) { &models.NotificationProvider{}, )) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) h := NewProxyHostHandler(db, nil, ns, nil) r := gin.New() api := r.Group("/api/v1") @@ -60,7 +60,7 @@ func setupTestRouterWithReferenceTables(t *testing.T) (*gin.Engine, *gorm.DB) { &models.NotificationProvider{}, )) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) h := NewProxyHostHandler(db, nil, ns, nil) r := gin.New() api := r.Group("/api/v1") @@ -86,7 +86,7 @@ func setupTestRouterWithUptime(t *testing.T) (*gin.Engine, *gorm.DB) { &models.Setting{}, )) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) us := services.NewUptimeService(db, ns) h := NewProxyHostHandler(db, nil, ns, us) r := gin.New() @@ -100,7 +100,7 @@ func TestProxyHostHandler_ResolveAccessListReference_TargetedBranches(t *testing t.Parallel() _, db := setupTestRouterWithReferenceTables(t) - h := NewProxyHostHandler(db, nil, services.NewNotificationService(db), nil) + h := NewProxyHostHandler(db, nil, services.NewNotificationService(db, nil), nil) resolved, err := h.resolveAccessListReference(true) require.Error(t, err) @@ -124,7 +124,7 @@ func TestProxyHostHandler_ResolveSecurityHeaderReference_TargetedBranches(t *tes t.Parallel() _, db := setupTestRouterWithReferenceTables(t) - h := NewProxyHostHandler(db, nil, services.NewNotificationService(db), nil) + h := NewProxyHostHandler(db, nil, services.NewNotificationService(db, nil), nil) resolved, err := h.resolveSecurityHeaderProfileReference(" ") require.NoError(t, err) @@ -327,7 +327,7 @@ func TestProxyHostDelete_WithUptimeCleanup(t *testing.T) { require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.UptimeMonitor{}, &models.UptimeHeartbeat{})) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) us := services.NewUptimeService(db, ns) h := NewProxyHostHandler(db, nil, ns, us) @@ -381,7 +381,7 @@ func TestProxyHostErrors(t *testing.T) { manager := caddy.NewManager(client, db, tmpDir, "", false, config.SecurityConfig{}) // Setup Handler - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) h := NewProxyHostHandler(db, manager, ns, nil) r := gin.New() api := r.Group("/api/v1") @@ -661,7 +661,7 @@ func TestProxyHostWithCaddyIntegration(t *testing.T) { manager := caddy.NewManager(client, db, tmpDir, "", false, config.SecurityConfig{}) // Setup Handler - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) h := NewProxyHostHandler(db, manager, ns, nil) r := gin.New() api := r.Group("/api/v1") @@ -1894,7 +1894,7 @@ func TestUpdate_IntegrationCaddyConfig(t *testing.T) { client := caddy.NewClientWithExpectedPort(caddyServer.URL, expectedPortFromURL(t, caddyServer.URL)) manager := caddy.NewManager(client, db, tmpDir, "", false, config.SecurityConfig{}) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) h := NewProxyHostHandler(db, manager, ns, nil) r := gin.New() api := r.Group("/api/v1") diff --git a/backend/internal/api/handlers/proxy_host_handler_update_test.go b/backend/internal/api/handlers/proxy_host_handler_update_test.go index ced2f799..6c628f5f 100644 --- a/backend/internal/api/handlers/proxy_host_handler_update_test.go +++ b/backend/internal/api/handlers/proxy_host_handler_update_test.go @@ -36,7 +36,7 @@ func setupUpdateTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) { &models.NotificationProvider{}, )) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) h := NewProxyHostHandler(db, nil, ns, nil) r := gin.New() @@ -933,7 +933,7 @@ func TestBulkUpdateSecurityHeaders_DBError_NonNotFound(t *testing.T) { } require.NoError(t, db.Create(&host).Error) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) h := NewProxyHostHandler(db, nil, ns, nil) r := gin.New() diff --git a/backend/internal/api/handlers/remote_server_handler_test.go b/backend/internal/api/handlers/remote_server_handler_test.go index 1e0956e3..a1e8e770 100644 --- a/backend/internal/api/handlers/remote_server_handler_test.go +++ b/backend/internal/api/handlers/remote_server_handler_test.go @@ -22,7 +22,7 @@ func setupRemoteServerTest_New(t *testing.T) (*gin.Engine, *handlers.RemoteServe // Ensure RemoteServer table exists _ = db.AutoMigrate(&models.RemoteServer{}) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns) r := gin.Default() diff --git a/backend/internal/api/handlers/security_event_intake_test.go b/backend/internal/api/handlers/security_event_intake_test.go index febf286c..010a530c 100644 --- a/backend/internal/api/handlers/security_event_intake_test.go +++ b/backend/internal/api/handlers/security_event_intake_test.go @@ -23,7 +23,7 @@ func TestSecurityEventIntakeCompileSuccess(t *testing.T) { db := SetupCompatibilityTestDB(t) // This test validates that the handler can be instantiated with all required dependencies - notificationService := services.NewNotificationService(db) + notificationService := services.NewNotificationService(db, nil) service := services.NewEnhancedSecurityNotificationService(db) securityService := services.NewSecurityService(db) managementCIDRs := []string{"127.0.0.0/8"} @@ -47,7 +47,7 @@ func TestSecurityEventIntakeCompileSuccess(t *testing.T) { func TestSecurityEventIntakeAuthLocalhost(t *testing.T) { db := SetupCompatibilityTestDB(t) - notificationService := services.NewNotificationService(db) + notificationService := services.NewNotificationService(db, nil) service := services.NewEnhancedSecurityNotificationService(db) managementCIDRs := []string{"10.0.0.0/8"} @@ -88,7 +88,7 @@ func TestSecurityEventIntakeAuthLocalhost(t *testing.T) { func TestSecurityEventIntakeAuthManagementCIDR(t *testing.T) { db := SetupCompatibilityTestDB(t) - notificationService := services.NewNotificationService(db) + notificationService := services.NewNotificationService(db, nil) service := services.NewEnhancedSecurityNotificationService(db) managementCIDRs := []string{"192.168.1.0/24", "10.0.0.0/8"} @@ -129,7 +129,7 @@ func TestSecurityEventIntakeAuthManagementCIDR(t *testing.T) { func TestSecurityEventIntakeAuthUnauthorizedIP(t *testing.T) { db := SetupCompatibilityTestDB(t) - notificationService := services.NewNotificationService(db) + notificationService := services.NewNotificationService(db, nil) service := services.NewEnhancedSecurityNotificationService(db) managementCIDRs := []string{"192.168.1.0/24"} @@ -175,7 +175,7 @@ func TestSecurityEventIntakeAuthUnauthorizedIP(t *testing.T) { func TestSecurityEventIntakeAuthInvalidIP(t *testing.T) { db := SetupCompatibilityTestDB(t) - notificationService := services.NewNotificationService(db) + notificationService := services.NewNotificationService(db, nil) service := services.NewEnhancedSecurityNotificationService(db) managementCIDRs := []string{"192.168.1.0/24"} @@ -234,7 +234,7 @@ func TestSecurityEventIntakeDispatchInvoked(t *testing.T) { } require.NoError(t, db.Create(provider).Error) - notificationService := services.NewNotificationService(db) + notificationService := services.NewNotificationService(db, nil) service := services.NewEnhancedSecurityNotificationService(db) managementCIDRs := []string{"127.0.0.0/8"} @@ -374,7 +374,7 @@ func TestSecurityEventIntakeDiscordOnly(t *testing.T) { } require.NoError(t, db.Create(webhookProvider).Error) - notificationService := services.NewNotificationService(db) + notificationService := services.NewNotificationService(db, nil) service := services.NewEnhancedSecurityNotificationService(db) managementCIDRs := []string{"127.0.0.0/8"} @@ -419,7 +419,7 @@ func TestSecurityEventIntakeDiscordOnly(t *testing.T) { func TestSecurityEventIntakeMalformedPayload(t *testing.T) { db := SetupCompatibilityTestDB(t) - notificationService := services.NewNotificationService(db) + notificationService := services.NewNotificationService(db, nil) service := services.NewEnhancedSecurityNotificationService(db) managementCIDRs := []string{"127.0.0.0/8"} @@ -454,7 +454,7 @@ func TestSecurityEventIntakeMalformedPayload(t *testing.T) { func TestSecurityEventIntakeIPv6Localhost(t *testing.T) { db := SetupCompatibilityTestDB(t) - notificationService := services.NewNotificationService(db) + notificationService := services.NewNotificationService(db, nil) service := services.NewEnhancedSecurityNotificationService(db) managementCIDRs := []string{"10.0.0.0/8"} diff --git a/backend/internal/api/handlers/security_notifications_single_source_test.go b/backend/internal/api/handlers/security_notifications_single_source_test.go index 9f1796b5..fbf05729 100644 --- a/backend/internal/api/handlers/security_notifications_single_source_test.go +++ b/backend/internal/api/handlers/security_notifications_single_source_test.go @@ -238,7 +238,7 @@ func TestR6_LegacyWrite410GoneNoMutation(t *testing.T) { func TestProviderCRUD_SecurityEventsIncludeCrowdSec(t *testing.T) { db := setupSingleSourceTestDB(t) - service := services.NewNotificationService(db) + service := services.NewNotificationService(db, nil) handler := NewNotificationProviderHandler(service) gin.SetMode(gin.TestMode) diff --git a/backend/internal/api/handlers/security_notifications_test.go b/backend/internal/api/handlers/security_notifications_test.go index f6c375a7..8e9f0494 100644 --- a/backend/internal/api/handlers/security_notifications_test.go +++ b/backend/internal/api/handlers/security_notifications_test.go @@ -40,7 +40,7 @@ func TestHandleSecurityEvent_TimestampZero(t *testing.T) { enhancedService := services.NewEnhancedSecurityNotificationService(db) securityService := services.NewSecurityService(db) - notificationService := services.NewNotificationService(db) + notificationService := services.NewNotificationService(db, nil) h := NewSecurityNotificationHandlerWithDeps(enhancedService, securityService, "/tmp", notificationService, []string{"127.0.0.0/8"}) w := httptest.NewRecorder() @@ -85,7 +85,7 @@ func TestHandleSecurityEvent_SendViaProvidersError(t *testing.T) { assert.NoError(t, err) securityService := services.NewSecurityService(db) - notificationService := services.NewNotificationService(db) + notificationService := services.NewNotificationService(db, nil) mockService := &mockFailingService{} h := NewSecurityNotificationHandlerWithDeps(mockService, securityService, "/tmp", notificationService, []string{"127.0.0.0/8"}) diff --git a/backend/internal/api/handlers/settings_handler.go b/backend/internal/api/handlers/settings_handler.go index 8f6cde94..f8029850 100644 --- a/backend/internal/api/handlers/settings_handler.go +++ b/backend/internal/api/handlers/settings_handler.go @@ -634,7 +634,7 @@ func (h *SettingsHandler) SendTestEmail(c *gin.Context) { ` - if err := h.MailService.SendEmail(req.To, "Charon - Test Email", htmlBody); err != nil { + if err := h.MailService.SendEmail(c.Request.Context(), []string{req.To}, "Charon - Test Email", htmlBody); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "error": err.Error(), diff --git a/backend/internal/api/handlers/uptime_handler_test.go b/backend/internal/api/handlers/uptime_handler_test.go index 2e190bcf..7f3dffa3 100644 --- a/backend/internal/api/handlers/uptime_handler_test.go +++ b/backend/internal/api/handlers/uptime_handler_test.go @@ -23,7 +23,7 @@ func setupUptimeHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) { db := handlers.OpenTestDB(t) require.NoError(t, db.AutoMigrate(&models.UptimeMonitor{}, &models.UptimeHeartbeat{}, &models.UptimeHost{}, &models.RemoteServer{}, &models.NotificationProvider{}, &models.Notification{}, &models.ProxyHost{})) - ns := services.NewNotificationService(db) + ns := services.NewNotificationService(db, nil) service := services.NewUptimeService(db, ns) handler := handlers.NewUptimeHandler(service) diff --git a/backend/internal/api/handlers/uptime_monitor_initial_state_test.go b/backend/internal/api/handlers/uptime_monitor_initial_state_test.go index f18af636..61ab01bc 100644 --- a/backend/internal/api/handlers/uptime_monitor_initial_state_test.go +++ b/backend/internal/api/handlers/uptime_monitor_initial_state_test.go @@ -26,7 +26,7 @@ func TestUptimeMonitorInitialStatePending(t *testing.T) { _ = db.AutoMigrate(&models.UptimeMonitor{}, &models.UptimeHost{}) // Create handler with service - notificationService := services.NewNotificationService(db) + notificationService := services.NewNotificationService(db, nil) uptimeService := services.NewUptimeService(db, notificationService) // Test: Create a monitor via service diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 3ef436ca..cd4ab284 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -205,7 +205,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM wsStatusHandler := handlers.NewWebSocketStatusHandler(wsTracker) // Notification Service (needed for multiple handlers) - notificationService := services.NewNotificationService(db) + notificationService := services.NewNotificationService(db, services.NewMailService(db)) // Ensure notify-only provider migration reconciliation at boot if err := notificationService.EnsureNotifyOnlyProviderMigration(context.Background()); err != nil { diff --git a/backend/internal/services/coverage_boost_test.go b/backend/internal/services/coverage_boost_test.go index 60c63b50..cb4e0029 100644 --- a/backend/internal/services/coverage_boost_test.go +++ b/backend/internal/services/coverage_boost_test.go @@ -1,6 +1,7 @@ package services import ( + "context" "net" "testing" @@ -120,7 +121,7 @@ func TestCoverageBoost_ErrorPaths(t *testing.T) { }) t.Run("NotificationService_ListTemplates_EmptyDB", func(t *testing.T) { - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Should not error with empty db templates, err := svc.ListTemplates() @@ -130,7 +131,7 @@ func TestCoverageBoost_ErrorPaths(t *testing.T) { }) t.Run("NotificationService_GetTemplate_NotFound", func(t *testing.T) { - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Test with non-existent ID _, err := svc.GetTemplate("nonexistent") @@ -227,7 +228,7 @@ func TestCoverageBoost_MailService_ErrorPaths(t *testing.T) { t.Run("SendEmail_NoConfig", func(t *testing.T) { // With empty config, should error - err := svc.SendEmail("test@example.com", "Subject", "Body") + err := svc.SendEmail(context.Background(), []string{"test@example.com"}, "Subject", "Body") assert.Error(t, err) }) } @@ -426,7 +427,7 @@ func TestCoverageBoost_MailService_SendSSL(t *testing.T) { require.NoError(t, err) // Try to send - should fail with connection error - err = svc.SendEmail("test@example.com", "Test", "Body") + err = svc.SendEmail(context.Background(), []string{"test@example.com"}, "Test", "Body") assert.Error(t, err) }) @@ -444,7 +445,7 @@ func TestCoverageBoost_MailService_SendSSL(t *testing.T) { require.NoError(t, err) // Try to send - should fail with connection error - err = svc.SendEmail("test@example.com", "Test", "Body") + err = svc.SendEmail(context.Background(), []string{"test@example.com"}, "Test", "Body") assert.Error(t, err) }) } @@ -523,7 +524,7 @@ func TestCoverageBoost_NotificationService_Providers(t *testing.T) { err = db.AutoMigrate(&models.NotificationProvider{}) require.NoError(t, err) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) t.Run("ListProviders_EmptyDB", func(t *testing.T) { providers, err := svc.ListProviders() @@ -591,7 +592,7 @@ func TestCoverageBoost_NotificationService_CRUD(t *testing.T) { err = db.AutoMigrate(&models.Notification{}) require.NoError(t, err) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) t.Run("List_EmptyDB", func(t *testing.T) { notifs, err := svc.List(false) diff --git a/backend/internal/services/mail_service.go b/backend/internal/services/mail_service.go index 0e9794a1..4e9ff39f 100644 --- a/backend/internal/services/mail_service.go +++ b/backend/internal/services/mail_service.go @@ -2,6 +2,7 @@ package services import ( "bytes" + "context" "crypto/tls" "errors" "fmt" @@ -22,6 +23,35 @@ var errEmailHeaderInjection = errors.New("email header value contains CR/LF") var errInvalidBaseURLForInvite = errors.New("baseURL must start with http:// or https:// and cannot include path components") +// ErrTooManyRecipients is returned when the recipient list exceeds the maximum allowed. +var ErrTooManyRecipients = errors.New("too many recipients: maximum is 20") + +// ErrInvalidRecipient is returned when a recipient address fails RFC 5322 validation. +var ErrInvalidRecipient = errors.New("invalid recipient address") + +// MailServiceInterface allows mocking MailService in tests. +type MailServiceInterface interface { + IsConfigured() bool + SendEmail(ctx context.Context, to []string, subject, htmlBody string) error +} + +// validateEmailRecipients validates a list of email recipients. +// It rejects lists exceeding 20, addresses containing CR/LF, and addresses failing RFC 5322 parsing. +func validateEmailRecipients(recipients []string) error { + if len(recipients) > 20 { + return ErrTooManyRecipients + } + for _, r := range recipients { + if strings.ContainsAny(r, "\r\n") { + return fmt.Errorf("%w: %s", ErrInvalidRecipient, r) + } + if _, err := mail.ParseAddress(r); err != nil { + return fmt.Errorf("%w: %s", ErrInvalidRecipient, r) + } + } + return nil +} + // encodeSubject encodes the email subject line using MIME Q-encoding (RFC 2047). // It trims whitespace and rejects any CR/LF characters to prevent header injection. func encodeSubject(subject string) (string, error) { @@ -261,9 +291,13 @@ func (s *MailService) TestConnection() error { return nil } -// SendEmail sends an email using the configured SMTP settings. -// The to address and subject are sanitized to prevent header injection. -func (s *MailService) SendEmail(to, subject, htmlBody string) error { +// SendEmail sends an email using the configured SMTP settings to each recipient. +// One email is sent per recipient (no BCC). The context is checked between sends. +func (s *MailService) SendEmail(ctx context.Context, to []string, subject, htmlBody string) error { + if err := validateEmailRecipients(to); err != nil { + return err + } + config, err := s.GetSMTPConfig() if err != nil { return err @@ -273,38 +307,21 @@ func (s *MailService) SendEmail(to, subject, htmlBody string) error { return errors.New("SMTP not configured") } - // Validate and encode subject + // Validate and encode subject once for all recipients encodedSubject, err := encodeSubject(subject) if err != nil { return fmt.Errorf("invalid subject: %w", err) } - // Validate recipient address (for SMTP envelope use) - toAddr, err := parseEmailAddressForHeader(headerTo, to) - if err != nil { - return fmt.Errorf("invalid recipient address: %w", err) - } - fromAddr, err := parseEmailAddressForHeader(headerFrom, config.FromAddress) if err != nil { return fmt.Errorf("invalid from address: %w", err) } - // Build the email message (headers are validated and formatted) - // Note: toAddr is only used for SMTP envelope; message headers use undisclosed recipients - msg, err := s.buildEmail(fromAddr, toAddr, nil, encodedSubject, htmlBody) - if err != nil { - return err - } - fromEnvelope := fromAddr.Address - toEnvelope := toAddr.Address if err := rejectCRLF(fromEnvelope); err != nil { return fmt.Errorf("invalid from address: %w", err) } - if err := rejectCRLF(toEnvelope); err != nil { - return fmt.Errorf("invalid recipient address: %w", err) - } addr := fmt.Sprintf("%s:%d", config.Host, config.Port) var auth smtp.Auth @@ -312,15 +329,46 @@ func (s *MailService) SendEmail(to, subject, htmlBody string) error { auth = smtp.PlainAuth("", config.Username, config.Password, config.Host) } - switch config.Encryption { - case "ssl": - return s.sendSSL(addr, config, auth, fromEnvelope, toEnvelope, msg) - case "starttls": - return s.sendSTARTTLS(addr, config, auth, fromEnvelope, toEnvelope, msg) - default: - // codeql[go/email-injection] Safe: header values reject CR/LF; addresses parsed by net/mail; body dot-stuffed; tests in mail_service_test.go cover CRLF attempts. - return smtp.SendMail(addr, auth, fromEnvelope, []string{toEnvelope}, msg) + for _, recipient := range to { + if err := ctx.Err(); err != nil { + return fmt.Errorf("context cancelled: %w", err) + } + + toAddr, err := parseEmailAddressForHeader(headerTo, recipient) + if err != nil { + return fmt.Errorf("invalid recipient address: %w", err) + } + + // Build the email message (headers are validated and formatted) + // Note: toAddr is only used for SMTP envelope; message headers use undisclosed recipients + msg, err := s.buildEmail(fromAddr, toAddr, nil, encodedSubject, htmlBody) + if err != nil { + return err + } + + toEnvelope := toAddr.Address + if err := rejectCRLF(toEnvelope); err != nil { + return fmt.Errorf("invalid recipient address: %w", err) + } + + switch config.Encryption { + case "ssl": + if err := s.sendSSL(addr, config, auth, fromEnvelope, toEnvelope, msg); err != nil { + return err + } + case "starttls": + if err := s.sendSTARTTLS(addr, config, auth, fromEnvelope, toEnvelope, msg); err != nil { + return err + } + default: + // Safe: CRLF rejected in header values; address parsed by net/mail; body dot-stuffed; see buildEmail() and rejectCRLF(). + if err := smtp.SendMail(addr, auth, fromEnvelope, []string{toEnvelope}, msg); err != nil { // codeql[go/email-injection] + return err + } + } } + + return nil } // buildEmail constructs a properly formatted email message with validated headers. @@ -486,9 +534,8 @@ func (s *MailService) sendSSL(addr string, config *SMTPConfig, auth smtp.Auth, f return fmt.Errorf("DATA failed: %w", err) } - // Security Note: msg built by buildEmail() with header/body sanitization - // See buildEmail() for injection protection details - if _, writeErr := w.Write(msg); writeErr != nil { + // Safe: msg is built by buildEmail() which rejects CRLF in headers and sanitizes the body; net/smtp data.Writer dot-stuffs per RFC 5321. + if _, writeErr := w.Write(msg); writeErr != nil { // codeql[go/email-injection] return fmt.Errorf("failed to write message: %w", writeErr) } @@ -539,9 +586,8 @@ func (s *MailService) sendSTARTTLS(addr string, config *SMTPConfig, auth smtp.Au return fmt.Errorf("DATA failed: %w", err) } - // Security Note: msg built by buildEmail() with header/body sanitization - // See buildEmail() for injection protection details - if _, err := w.Write(msg); err != nil { + // Safe: msg is built by buildEmail() which rejects CRLF in headers and sanitizes the body; net/smtp data.Writer dot-stuffs per RFC 5321. + if _, err := w.Write(msg); err != nil { // codeql[go/email-injection] return fmt.Errorf("failed to write message: %w", err) } @@ -626,5 +672,8 @@ func (s *MailService) SendInvite(email, inviteToken, appName, baseURL string) er logger.Log().WithField("email", util.SanitizeForLog(email)).Info("Sending invite email") // SendEmail will validate and encode the subject - return s.SendEmail(email, subject, body.String()) + return s.SendEmail(context.Background(), []string{email}, subject, body.String()) } + +// Compile-time assertion: MailService must satisfy MailServiceInterface. +var _ MailServiceInterface = (*MailService)(nil) diff --git a/backend/internal/services/mail_service_test.go b/backend/internal/services/mail_service_test.go index c2e072b5..85f5474b 100644 --- a/backend/internal/services/mail_service_test.go +++ b/backend/internal/services/mail_service_test.go @@ -3,6 +3,7 @@ package services import ( "bufio" "bytes" + "context" "crypto/rand" "crypto/rsa" "crypto/tls" @@ -16,6 +17,7 @@ import ( "os" "path/filepath" "strconv" + "fmt" "strings" "sync" "testing" @@ -384,7 +386,7 @@ func TestMailService_SendEmail_NotConfigured(t *testing.T) { db := setupMailTestDB(t) svc := NewMailService(db) - err := svc.SendEmail("test@example.com", "Subject", "

Body

") + err := svc.SendEmail(context.Background(), []string{"test@example.com"}, "Subject", "

Body

") assert.Error(t, err) assert.Contains(t, err.Error(), "not configured") } @@ -400,7 +402,7 @@ func TestMailService_SendEmail_RejectsCRLFInSubject(t *testing.T) { } require.NoError(t, svc.SaveSMTPConfig(config)) - err := svc.SendEmail("recipient@example.com", "Hello\r\nBcc: evil@example.com", "Body") + err := svc.SendEmail(context.Background(), []string{"recipient@example.com"}, "Hello\r\nBcc: evil@example.com", "Body") assert.Error(t, err) assert.Contains(t, err.Error(), "invalid subject") } @@ -531,7 +533,7 @@ func TestMailService_SendEmail_InvalidRecipient(t *testing.T) { } require.NoError(t, svc.SaveSMTPConfig(config)) - err := svc.SendEmail("invalid\r\nemail", "Subject", "Body") + err := svc.SendEmail(context.Background(), []string{"invalid\r\nemail"}, "Subject", "Body") assert.Error(t, err) assert.Contains(t, err.Error(), "invalid recipient") } @@ -547,7 +549,7 @@ func TestMailService_SendEmail_InvalidFromAddress(t *testing.T) { } require.NoError(t, svc.SaveSMTPConfig(config)) - err := svc.SendEmail("test@example.com", "Subject", "Body") + err := svc.SendEmail(context.Background(), []string{"test@example.com"}, "Subject", "Body") assert.Error(t, err) assert.Contains(t, err.Error(), "invalid from address") } @@ -578,7 +580,7 @@ func TestMailService_SendEmail_EncryptionModes(t *testing.T) { } require.NoError(t, svc.SaveSMTPConfig(config)) - err := svc.SendEmail("recipient@example.com", "Test", "Body") + err := svc.SendEmail(context.Background(), []string{"recipient@example.com"}, "Test", "Body") assert.Error(t, err) }) } @@ -658,7 +660,7 @@ func TestMailService_SendEmail_CRLFInjection_Comprehensive(t *testing.T) { require.NoError(t, svc.SaveSMTPConfig(&testConfig)) } - err := svc.SendEmail(tc.to, tc.subject, "

Normal body

") + err := svc.SendEmail(context.Background(), []string{tc.to}, tc.subject, "

Normal body

") assert.Error(t, err, tc.description) assert.Contains(t, err.Error(), "invalid", "Error should indicate invalid input") }) @@ -1057,7 +1059,7 @@ func TestMailService_SendEmail_STARTTLSSuccess(t *testing.T) { })) // With fixed cert trust, STARTTLS connection and email send succeed - err = svc.SendEmail("recipient@example.com", "Subject", "Body") + err = svc.SendEmail(context.Background(), []string{"recipient@example.com"}, "Subject", "Body") require.NoError(t, err) } @@ -1086,7 +1088,7 @@ func TestMailService_SendEmail_SSLSuccess(t *testing.T) { })) // With fixed cert trust, SSL connection and email send succeed - err = svc.SendEmail("recipient@example.com", "Subject", "Body") + err = svc.SendEmail(context.Background(), []string{"recipient@example.com"}, "Subject", "Body") require.NoError(t, err) } @@ -1356,3 +1358,102 @@ func handleSMTPConn(conn net.Conn, tlsConf *tls.Config, supportStartTLS bool, re } } } + +func TestValidateEmailRecipients_Empty(t *testing.T) { + err := validateEmailRecipients([]string{}) + assert.NoError(t, err) +} + +func TestValidateEmailRecipients_Valid(t *testing.T) { + err := validateEmailRecipients([]string{"a@b.com", "c@d.org"}) + assert.NoError(t, err) +} + +func TestValidateEmailRecipients_TooMany(t *testing.T) { + recipients := make([]string, 21) + for i := range recipients { + recipients[i] = "a@b.com" + } + err := validateEmailRecipients(recipients) + assert.ErrorIs(t, err, ErrTooManyRecipients) +} + +func TestValidateEmailRecipients_CRLFInRecipient(t *testing.T) { + err := validateEmailRecipients([]string{"victim@example.com\r\nBcc: evil@bad.com"}) + assert.ErrorIs(t, err, ErrInvalidRecipient) +} + +func TestValidateEmailRecipients_InvalidFormat(t *testing.T) { + err := validateEmailRecipients([]string{"not-an-email"}) + assert.ErrorIs(t, err, ErrInvalidRecipient) +} + +func TestValidateEmailRecipients_ExactlyTwenty(t *testing.T) { + recipients := make([]string, 20) + for i := range recipients { + recipients[i] = "a@b.com" + } + err := validateEmailRecipients(recipients) + assert.NoError(t, err) +} + +func TestSendEmail_TooManyRecipients(t *testing.T) { + db := setupMailTestDB(t) + svc := NewMailService(db) + + cfg := &SMTPConfig{Host: "smtp.example.com", Port: 587, FromAddress: "from@example.com"} + require.NoError(t, svc.SaveSMTPConfig(cfg)) + + recipients := make([]string, 21) + for i := range recipients { + recipients[i] = fmt.Sprintf("user%d@example.com", i) + } + err := svc.SendEmail(context.Background(), recipients, "Subject", "

Body

") + require.Error(t, err) + assert.ErrorIs(t, err, ErrTooManyRecipients) +} + +func TestSendEmail_HeaderInjectionInRecipient(t *testing.T) { + db := setupMailTestDB(t) + svc := NewMailService(db) + + cfg := &SMTPConfig{Host: "smtp.example.com", Port: 587, FromAddress: "from@example.com"} + require.NoError(t, svc.SaveSMTPConfig(cfg)) + + err := svc.SendEmail(context.Background(), []string{"bad\r\naddr@test.com"}, "Subject", "

Body

") + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidRecipient) +} + +func TestSendEmail_InvalidRFC5322Recipient(t *testing.T) { + db := setupMailTestDB(t) + svc := NewMailService(db) + + cfg := &SMTPConfig{Host: "smtp.example.com", Port: 587, FromAddress: "from@example.com"} + require.NoError(t, svc.SaveSMTPConfig(cfg)) + + err := svc.SendEmail(context.Background(), []string{"notanemail"}, "Subject", "

Body

") + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidRecipient) +} + +func TestSendEmail_ValidMultipleRecipients(t *testing.T) { + db := setupMailTestDB(t) + svc := NewMailService(db) + + // Use a mock SMTP server to capture the connection attempt. + // Since we're not running a real SMTP server, the actual send will fail, + // but validation (the part being tested) must pass — error must NOT be ErrInvalidRecipient or ErrTooManyRecipients. + cfg := &SMTPConfig{Host: "127.0.0.1", Port: 19999, FromAddress: "from@example.com"} + require.NoError(t, svc.SaveSMTPConfig(cfg)) + + err := svc.SendEmail(context.Background(), + []string{"alice@example.com", "bob@example.com", "carol@example.com"}, + "Test Subject", "

Hello

", + ) + // Validation must pass; connection refused error expected (no SMTP server running) + if err != nil { + assert.NotErrorIs(t, err, ErrInvalidRecipient) + assert.NotErrorIs(t, err, ErrTooManyRecipients) + } +} diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 3eb4d9df..4afbd308 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "html" "net" "net/http" neturl "net/url" @@ -27,12 +28,14 @@ import ( type NotificationService struct { DB *gorm.DB httpWrapper *notifications.HTTPWrapper + mailService MailServiceInterface } -func NewNotificationService(db *gorm.DB) *NotificationService { +func NewNotificationService(db *gorm.DB, mailService MailServiceInterface) *NotificationService { return &NotificationService{ DB: db, httpWrapper: notifications.NewNotifyHTTPWrapper(), + mailService: mailService, } } @@ -225,6 +228,10 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title Warn("Skipping dispatch because provider type is disabled for notify dispatch") continue } + if strings.ToLower(strings.TrimSpace(provider.Type)) == "email" { + go s.dispatchEmail(ctx, provider, eventType, title, message) + continue + } go func(p models.NotificationProvider) { if !supportsJSONTemplates(p.Type) { logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).WithField("type", p.Type).Warn("Provider type is not supported by notify-only runtime") @@ -238,6 +245,38 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title } } +// dispatchEmail sends an email notification for the given provider. +// It runs in a goroutine; all errors are logged rather than returned. +func (s *NotificationService) dispatchEmail(ctx context.Context, p models.NotificationProvider, _, title, message string) { + if s.mailService == nil || !s.mailService.IsConfigured() { + logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).Warn("Email provider is not configured, skipping dispatch") + return + } + + rawRecipients := strings.Split(p.URL, ",") + recipients := make([]string, 0, len(rawRecipients)) + for _, r := range rawRecipients { + if trimmed := strings.TrimSpace(r); trimmed != "" { + recipients = append(recipients, trimmed) + } + } + + if len(recipients) == 0 { + logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).Warn("Email provider has no recipients configured") + return + } + + subject := fmt.Sprintf("[Charon Alert] %s", title) + htmlBody := "

" + html.EscapeString(title) + "

" + html.EscapeString(message) + "

" + + timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + if err := s.mailService.SendEmail(timeoutCtx, recipients, subject, htmlBody); err != nil { + logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Failed to send email notification") + } +} + // webhookDoRequestFunc is a test hook for outbound JSON webhook requests. // In production it defaults to (*http.Client).Do. var webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { diff --git a/backend/internal/services/notification_service_discord_only_test.go b/backend/internal/services/notification_service_discord_only_test.go index ea0bee0f..a43afd5b 100644 --- a/backend/internal/services/notification_service_discord_only_test.go +++ b/backend/internal/services/notification_service_discord_only_test.go @@ -20,7 +20,7 @@ func TestDiscordOnly_CreateProviderRejectsUnsupported(t *testing.T) { require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) - service := NewNotificationService(db) + service := NewNotificationService(db, nil) testCases := []string{"slack", "telegram", "generic"} @@ -45,7 +45,7 @@ func TestDiscordOnly_CreateProviderAcceptsDiscord(t *testing.T) { require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) - service := NewNotificationService(db) + service := NewNotificationService(db, nil) provider := &models.NotificationProvider{ Name: "Test Discord", @@ -67,7 +67,7 @@ func TestDiscordOnly_CreateProviderAcceptsWebhook(t *testing.T) { require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) - service := NewNotificationService(db) + service := NewNotificationService(db, nil) provider := &models.NotificationProvider{ Name: "Test Webhook", @@ -84,7 +84,7 @@ func TestDiscordOnly_CreateProviderAcceptsGotifyWithOrWithoutToken(t *testing.T) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) - service := NewNotificationService(db) + service := NewNotificationService(db, nil) provider := &models.NotificationProvider{ Name: "Test Gotify", @@ -107,7 +107,7 @@ func TestDiscordOnly_CreateProviderAcceptsEmail(t *testing.T) { require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) - service := NewNotificationService(db) + service := NewNotificationService(db, nil) provider := &models.NotificationProvider{ Name: "Test Email", @@ -134,7 +134,7 @@ func TestDiscordOnly_UpdateProviderRejectsTypeMutation(t *testing.T) { } require.NoError(t, db.Create(&provider).Error) - service := NewNotificationService(db) + service := NewNotificationService(db, nil) updatedProvider := &models.NotificationProvider{ ID: "test-id", @@ -164,7 +164,7 @@ func TestDiscordOnly_UpdateProviderAllowsWebhookUpdates(t *testing.T) { } require.NoError(t, db.Create(&provider).Error) - service := NewNotificationService(db) + service := NewNotificationService(db, nil) updatedProvider := &models.NotificationProvider{ ID: "test-id", @@ -190,7 +190,7 @@ func TestDiscordOnly_TestProviderAllowsWebhookWithoutFeatureFlag(t *testing.T) { })) defer ts.Close() - service := NewNotificationService(db) + service := NewNotificationService(db, nil) provider := models.NotificationProvider{ Name: "Test Webhook", @@ -219,7 +219,7 @@ func TestDiscordOnly_MigrationDeprecatesNonDiscord(t *testing.T) { } require.NoError(t, db.Create(&webhookProvider).Error) - service := NewNotificationService(db) + service := NewNotificationService(db, nil) // Run migration err = service.EnsureNotifyOnlyProviderMigration(context.Background()) @@ -250,7 +250,7 @@ func TestDiscordOnly_MigrationMarksDiscordMigrated(t *testing.T) { } require.NoError(t, db.Create(&discordProvider).Error) - service := NewNotificationService(db) + service := NewNotificationService(db, nil) // Run migration err = service.EnsureNotifyOnlyProviderMigration(context.Background()) @@ -293,7 +293,7 @@ func TestDiscordOnly_MigrationIsIdempotent(t *testing.T) { require.NoError(t, db.Create(&p).Error) } - service := NewNotificationService(db) + service := NewNotificationService(db, nil) // Run migration first time err = service.EnsureNotifyOnlyProviderMigration(context.Background()) @@ -334,7 +334,7 @@ func TestDiscordOnly_MigrationIsTransactional(t *testing.T) { } require.NoError(t, db.Create(&provider).Error) - service := NewNotificationService(db) + service := NewNotificationService(db, nil) // First migration should succeed err = service.EnsureNotifyOnlyProviderMigration(context.Background()) @@ -362,7 +362,7 @@ func TestDiscordOnly_MigrationPreservesLegacyURL(t *testing.T) { } require.NoError(t, db.Create(&provider).Error) - service := NewNotificationService(db) + service := NewNotificationService(db, nil) err = service.EnsureNotifyOnlyProviderMigration(context.Background()) require.NoError(t, err) @@ -390,7 +390,7 @@ func TestDiscordOnly_SendExternalSkipsDeprecated(t *testing.T) { } require.NoError(t, db.Create(&deprecatedProvider).Error) - service := NewNotificationService(db) + service := NewNotificationService(db, nil) // SendExternal should skip deprecated provider silently service.SendExternal(context.Background(), "proxy_host", "Test", "Test message", nil) diff --git a/backend/internal/services/notification_service_json_test.go b/backend/internal/services/notification_service_json_test.go index fb68a8d9..7cf968c5 100644 --- a/backend/internal/services/notification_service_json_test.go +++ b/backend/internal/services/notification_service_json_test.go @@ -48,7 +48,7 @@ func TestSendJSONPayload_DiscordIPHostRejected(t *testing.T) { require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Type: "discord", @@ -88,7 +88,7 @@ func TestSendJSONPayload_UsesStoredHostnameURLWithoutHostMutation(t *testing.T) db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Mock Discord validation to allow test server URLs origValidateDiscordFunc := validateDiscordProviderURLFunc @@ -158,7 +158,7 @@ func TestSendJSONPayload_Discord(t *testing.T) { require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Type: "discord", @@ -193,7 +193,7 @@ func TestSendJSONPayload_Slack(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Type: "slack", @@ -226,7 +226,7 @@ func TestSendJSONPayload_Gotify(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Type: "gotify", @@ -249,7 +249,7 @@ func TestSendJSONPayload_TemplateTimeout(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Mock Discord validation to allow private IP check to run origValidateDiscordFunc := validateDiscordProviderURLFunc @@ -286,7 +286,7 @@ func TestSendJSONPayload_TemplateSizeLimit(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Create a template larger than 10KB largeTemplate := strings.Repeat("x", 11*1024) @@ -311,7 +311,7 @@ func TestSendJSONPayload_DiscordValidation(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Type: "discord", @@ -334,7 +334,7 @@ func TestSendJSONPayload_DiscordValidation_MissingMessage(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Type: "discord", @@ -354,7 +354,7 @@ func TestSendJSONPayload_SlackValidation(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Slack payload without text or blocks should fail provider := models.NotificationProvider{ @@ -377,7 +377,7 @@ func TestSendJSONPayload_GotifyValidation(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Gotify payload without message should fail provider := models.NotificationProvider{ @@ -400,7 +400,7 @@ func TestSendJSONPayload_InvalidJSON(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Type: "discord", @@ -455,7 +455,7 @@ func TestSendExternal_UsesJSONForSupportedServices(t *testing.T) { } db.Create(&provider) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) svc.SendExternal(context.Background(), "proxy_host", "Test", "Message", nil) // Give goroutine time to execute @@ -488,7 +488,7 @@ func TestTestProvider_UsesJSONForSupportedServices(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Type: "discord", diff --git a/backend/internal/services/notification_service_template_test.go b/backend/internal/services/notification_service_template_test.go index c49a4c9f..54394344 100644 --- a/backend/internal/services/notification_service_template_test.go +++ b/backend/internal/services/notification_service_template_test.go @@ -18,7 +18,7 @@ func TestNotificationService_TemplateCRUD(t *testing.T) { require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{})) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) tmpl := &models.NotificationTemplate{ Name: "Custom", diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index e6c421f7..759a4f19 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -10,6 +10,8 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" + "sync" "sync/atomic" "testing" "time" @@ -33,7 +35,7 @@ func setupNotificationTestDB(t *testing.T) *gorm.DB { func TestNotificationService_Create(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) notif, err := svc.Create(models.NotificationTypeInfo, "Test", "Message") require.NoError(t, err) @@ -44,7 +46,7 @@ func TestNotificationService_Create(t *testing.T) { func TestNotificationService_List(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) _, _ = svc.Create(models.NotificationTypeInfo, "N1", "M1") _, _ = svc.Create(models.NotificationTypeInfo, "N2", "M2") @@ -64,7 +66,7 @@ func TestNotificationService_List(t *testing.T) { func TestNotificationService_MarkAsRead(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) notif, _ := svc.Create(models.NotificationTypeInfo, "N1", "M1") @@ -78,7 +80,7 @@ func TestNotificationService_MarkAsRead(t *testing.T) { func TestNotificationService_MarkAllAsRead(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) _, _ = svc.Create(models.NotificationTypeInfo, "N1", "M1") _, _ = svc.Create(models.NotificationTypeInfo, "N2", "M2") @@ -93,7 +95,7 @@ func TestNotificationService_MarkAllAsRead(t *testing.T) { func TestNotificationService_Providers(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Create provider := models.NotificationProvider{ @@ -128,7 +130,7 @@ func TestNotificationService_Providers(t *testing.T) { func TestNotificationService_TestProvider_Webhook(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Mock validation and webhook request for testing origValidateDiscordFunc := validateDiscordProviderURLFunc @@ -166,7 +168,7 @@ func TestNotificationService_TestProvider_Webhook(t *testing.T) { func TestNotificationService_SendExternal(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) received := make(chan struct{}) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -203,7 +205,7 @@ func TestNotificationService_SendExternal(t *testing.T) { func TestNotificationService_SendExternal_MinimalVsDetailedTemplates(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Mock validation only - allow real HTTP calls to test servers origValidateDiscordFunc := validateDiscordProviderURLFunc @@ -286,7 +288,7 @@ func TestNotificationService_SendExternal_MinimalVsDetailedTemplates(t *testing. func TestNotificationService_SendExternal_Filtered(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) received := make(chan struct{}) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -359,7 +361,7 @@ func TestNormalizeURL(t *testing.T) { func TestNotificationService_SendCustomWebhook_Errors(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) t.Run("invalid URL", func(t *testing.T) { provider := models.NotificationProvider{ @@ -467,7 +469,7 @@ func TestNotificationService_SendCustomWebhook_Errors(t *testing.T) { func TestNotificationService_SendCustomWebhook_PropagatesRequestID(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) received := make(chan string, 1) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -493,7 +495,7 @@ func TestNotificationService_SendCustomWebhook_PropagatesRequestID(t *testing.T) func TestNotificationService_TestProvider_Errors(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) t.Run("unsupported provider type", func(t *testing.T) { provider := models.NotificationProvider{ @@ -617,7 +619,7 @@ func TestSSRF_URLValidation_ComprehensiveBlocking(t *testing.T) { func TestSSRF_WebhookIntegration(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) t.Run("blocks private IP webhook", func(t *testing.T) { provider := models.NotificationProvider{ @@ -660,7 +662,7 @@ func TestSSRF_WebhookIntegration(t *testing.T) { func TestNotificationService_SendExternal_EdgeCases(t *testing.T) { t.Run("no enabled providers", func(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Name: "Disabled", @@ -677,7 +679,7 @@ func TestNotificationService_SendExternal_EdgeCases(t *testing.T) { t.Run("provider filtered by category", func(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("Should not call webhook") @@ -722,7 +724,7 @@ func TestNotificationService_SendExternal_EdgeCases(t *testing.T) { t.Run("custom data passed to webhook", func(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Mock validation only - allow real HTTP calls to test server origValidateDiscordFunc := validateDiscordProviderURLFunc @@ -764,7 +766,7 @@ func TestNotificationService_SendExternal_EdgeCases(t *testing.T) { func TestNotificationService_RenderTemplate(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Minimal template provider := models.NotificationProvider{Type: "webhook", Template: "minimal"} @@ -784,7 +786,7 @@ func TestNotificationService_RenderTemplate(t *testing.T) { func TestNotificationService_CreateProvider_Validation(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) t.Run("creates provider with defaults", func(t *testing.T) { provider := models.NotificationProvider{ @@ -860,7 +862,7 @@ func TestNotificationService_IsPrivateIP(t *testing.T) { func TestNotificationService_CreateProvider_InvalidCustomTemplate(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) t.Run("invalid custom template on create", func(t *testing.T) { provider := models.NotificationProvider{ @@ -897,7 +899,7 @@ func TestNotificationService_CreateProvider_InvalidCustomTemplate(t *testing.T) func TestRenderTemplate_TemplateParseError(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Template: "custom", @@ -918,7 +920,7 @@ func TestRenderTemplate_TemplateParseError(t *testing.T) { func TestRenderTemplate_TemplateExecutionError(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Template: "custom", @@ -943,7 +945,7 @@ func TestRenderTemplate_TemplateExecutionError(t *testing.T) { func TestRenderTemplate_InvalidJSONOutput(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Template: "custom", @@ -966,7 +968,7 @@ func TestRenderTemplate_InvalidJSONOutput(t *testing.T) { func TestSendCustomWebhook_HTTPStatusCodeErrors(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) errorCodes := []int{400, 404, 500, 502, 503} @@ -1005,7 +1007,7 @@ func TestSendCustomWebhook_HTTPStatusCodeErrors(t *testing.T) { func TestSendCustomWebhook_TemplateSelection(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) tests := []struct { name string @@ -1093,7 +1095,7 @@ func TestSendCustomWebhook_TemplateSelection(t *testing.T) { func TestSendCustomWebhook_EmptyCustomTemplateDefaultsToMinimal(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) var receivedBody map[string]any @@ -1134,7 +1136,7 @@ func TestSendCustomWebhook_EmptyCustomTemplateDefaultsToMinimal(t *testing.T) { func TestCreateProvider_EmptyCustomTemplateAllowed(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) provider := &models.NotificationProvider{ Name: "empty-template", @@ -1151,7 +1153,7 @@ func TestCreateProvider_EmptyCustomTemplateAllowed(t *testing.T) { func TestUpdateProvider_NonCustomTemplateSkipsValidation(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) provider := &models.NotificationProvider{ Name: "test", @@ -1206,7 +1208,7 @@ func TestIsPrivateIP_EdgeCases(t *testing.T) { func TestSendCustomWebhook_ContextCancellation(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Create a server that delays response server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1238,7 +1240,7 @@ func TestSendCustomWebhook_ContextCancellation(t *testing.T) { func TestSendExternal_UnknownEventTypeSendsToAll(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) var callCount atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1280,7 +1282,7 @@ func TestSendExternal_UnknownEventTypeSendsToAll(t *testing.T) { func TestCreateProvider_ValidCustomTemplate(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) provider := &models.NotificationProvider{ Name: "valid-custom", @@ -1297,7 +1299,7 @@ func TestCreateProvider_ValidCustomTemplate(t *testing.T) { func TestUpdateProvider_ValidCustomTemplate(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) provider := &models.NotificationProvider{ Name: "test", @@ -1317,7 +1319,7 @@ func TestUpdateProvider_ValidCustomTemplate(t *testing.T) { func TestRenderTemplate_MinimalAndDetailedTemplates(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) data := map[string]any{ "Title": "Test Title", @@ -1369,7 +1371,7 @@ func TestRenderTemplate_MinimalAndDetailedTemplates(t *testing.T) { func TestSendJSONPayload_ServiceSpecificValidation(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) t.Run("discord_message_is_normalized_to_content", func(t *testing.T) { originalDo := webhookDoRequestFunc @@ -1589,7 +1591,7 @@ func TestSendExternal_AllEventTypes(t *testing.T) { for _, et := range eventTypes { t.Run(et.eventType, func(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Mock Discord validation to allow test server URL origValidateDiscordFunc := validateDiscordProviderURLFunc @@ -1710,7 +1712,7 @@ func TestNotificationService_SendExternal_SecurityEventRouting(t *testing.T) { for _, tc := range eventCases { t.Run(tc.name, func(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) origValidate := validateDiscordProviderURLFunc defer func() { validateDiscordProviderURLFunc = origValidate }() @@ -1746,7 +1748,7 @@ func TestNotificationService_SendExternal_SecurityEventRouting(t *testing.T) { func TestNotificationService_UpdateProvider_ReturnsErrorWhenProviderMissing(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) err := svc.UpdateProvider(&models.NotificationProvider{ ID: "missing-id", @@ -1758,7 +1760,7 @@ func TestNotificationService_UpdateProvider_ReturnsErrorWhenProviderMissing(t *t func TestNotificationService_EnsureNotifyOnlyProviderMigration_QueryProvidersError(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) sqlDB, err := db.DB() require.NoError(t, err) @@ -1791,7 +1793,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration_UpdateError(t *te roDB, err := gorm.Open(sqlite.Open(roDSN), &gorm.Config{}) require.NoError(t, err) - svc := NewNotificationService(roDB) + svc := NewNotificationService(roDB, nil) err = svc.EnsureNotifyOnlyProviderMigration(context.Background()) require.Error(t, err) assert.Contains(t, err.Error(), "failed to migrate notification provider") @@ -1808,7 +1810,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration_WrapsFindError(t require.NoError(t, err) // Intentionally do not migrate notification_providers table. - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) err = svc.EnsureNotifyOnlyProviderMigration(context.Background()) require.Error(t, err) assert.Contains(t, err.Error(), "failed to fetch notification providers for migration") @@ -1816,7 +1818,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration_WrapsFindError(t func TestTestProvider_NotifyOnlyRejectsUnsupportedProvider(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Test truly unsupported providers are rejected tests := []struct { @@ -1846,7 +1848,7 @@ func TestTestProvider_NotifyOnlyRejectsUnsupportedProvider(t *testing.T) { func TestTestProvider_DiscordUsesNotifyPathInPR1(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) serverCalled := atomic.Bool{} originalDo := webhookDoRequestFunc @@ -1871,7 +1873,7 @@ func TestTestProvider_DiscordUsesNotifyPathInPR1(t *testing.T) { func TestTestProvider_HTTPURLValidation(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) t.Run("blocks private IP", func(t *testing.T) { provider := models.NotificationProvider{ @@ -1918,7 +1920,7 @@ func TestTestProvider_HTTPURLValidation(t *testing.T) { func TestSendJSONPayload_TemplateExecutionError(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -1947,7 +1949,7 @@ func TestSendJSONPayload_TemplateExecutionError(t *testing.T) { func TestSendJSONPayload_InvalidJSONFromTemplate(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -1976,7 +1978,7 @@ func TestSendJSONPayload_InvalidJSONFromTemplate(t *testing.T) { func TestSendJSONPayload_RequestCreationError(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // This test verifies request creation doesn't panic on edge cases provider := models.NotificationProvider{ @@ -2002,7 +2004,7 @@ func TestSendJSONPayload_RequestCreationError(t *testing.T) { func TestRenderTemplate_CustomTemplateWithWhitespace(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Test template selection with various whitespace tests := []struct { @@ -2043,7 +2045,7 @@ func TestListTemplates_DBError(t *testing.T) { db, _ := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) _ = db.AutoMigrate(&models.NotificationTemplate{}) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Close the underlying connection to force error sqlDB, _ := db.DB() @@ -2058,7 +2060,7 @@ func TestSendExternal_DBFetchError(t *testing.T) { db, _ := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) _ = db.AutoMigrate(&models.NotificationProvider{}) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Close the underlying connection to force error sqlDB, _ := db.DB() @@ -2070,7 +2072,7 @@ func TestSendExternal_DBFetchError(t *testing.T) { func TestSendExternal_JSONPayloadError(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Create a provider that will fail JSON validation (discord without content/embeds) provider := models.NotificationProvider{ @@ -2091,7 +2093,7 @@ func TestSendExternal_JSONPayloadError(t *testing.T) { func TestSendJSONPayload_HTTPScheme(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Test both HTTP and HTTPS schemes schemes := []string{"http", "https"} @@ -2129,7 +2131,7 @@ func TestSendJSONPayload_HTTPScheme(t *testing.T) { func TestNotificationService_EnsureNotifyOnlyProviderMigration(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) ctx := context.Background() // Create test providers: discord (supported) and others (deprecated in discord-only rollout) @@ -2198,7 +2200,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration(t *testing.T) { func TestNotificationService_EnsureNotifyOnlyProviderMigration_PreservesLegacyURL(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) ctx := context.Background() // Create provider with URL but no legacy_url @@ -2222,7 +2224,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration_PreservesLegacyUR func TestNotificationService_EnsureNotifyOnlyProviderMigration_SkipsIfLegacyURLExists(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) ctx := context.Background() // Create provider with both URL and legacy_url already set @@ -2248,7 +2250,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration_SkipsIfLegacyURLE func TestNotificationService_EnsureNotifyOnlyProviderMigration_DBError(t *testing.T) { db, _ := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) _ = db.AutoMigrate(&models.NotificationProvider{}) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Close DB to force error sqlDB, _ := db.DB() @@ -2271,7 +2273,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration_DBError(t *testin // Success path is tested by TestNotificationService_EnsureNotifyOnlyProviderMigration func TestNotificationService_EnsureNotifyOnlyProviderMigration_FailsClosed(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) ctx := context.Background() // Create a Discord provider (the only type that gets migrated) @@ -2302,7 +2304,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration_FailsClosed(t *te func TestIsDispatchEnabled_GotifyDefaultTrue(t *testing.T) { db := setupNotificationTestDB(t) _ = db.AutoMigrate(&models.Setting{}) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // No feature flag row exists — should default to true assert.True(t, svc.isDispatchEnabled("gotify")) @@ -2311,7 +2313,7 @@ func TestIsDispatchEnabled_GotifyDefaultTrue(t *testing.T) { func TestIsDispatchEnabled_WebhookDefaultTrue(t *testing.T) { db := setupNotificationTestDB(t) _ = db.AutoMigrate(&models.Setting{}) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // No feature flag row exists — should default to true assert.True(t, svc.isDispatchEnabled("webhook")) @@ -2328,7 +2330,7 @@ func TestIsSupportedNotificationProviderType_Email(t *testing.T) { func TestIsDispatchEnabled_EmailDefaultFalse(t *testing.T) { db := setupNotificationTestDB(t) _ = db.AutoMigrate(&models.Setting{}) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // No feature flag row — email defaults to false assert.False(t, svc.isDispatchEnabled("email")) @@ -2341,13 +2343,14 @@ func TestIsDispatchEnabled_EmailDefaultFalse(t *testing.T) { assert.True(t, svc.isDispatchEnabled("email")) } -// TestSendExternal_EmailProviderSkipsJSONTemplate covers the goroutine branch where -// an email provider is dispatch-enabled but not in supportsJSONTemplates — it logs -// a warning and returns without calling sendJSONPayload. -func TestSendExternal_EmailProviderSkipsJSONTemplate(t *testing.T) { +// TestSendExternal_EmailProvider_NilMailService_DoesNotPanic verifies that when an +// email provider is enabled but the mail service is nil, SendExternal dispatches +// the goroutine which early-returns without panicking. The type == "email" branch +// calls dispatchEmail and continues — it never reaches supportsJSONTemplates. +func TestSendExternal_EmailProvider_NilMailService_DoesNotPanic(t *testing.T) { db := setupNotificationTestDB(t) _ = db.AutoMigrate(&models.Setting{}) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Enable the email feature flag so isDispatchEnabled("email") returns true. require.NoError(t, db.Create(&models.Setting{ @@ -2374,7 +2377,7 @@ func TestSendExternal_EmailProviderSkipsJSONTemplate(t *testing.T) { // attempting an HTTP send. func TestTestProvider_EmailRejectsJSONTemplateStep(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) provider := models.NotificationProvider{ Type: "email", @@ -2390,7 +2393,7 @@ func TestTestProvider_EmailRejectsJSONTemplateStep(t *testing.T) { func TestTestProvider_GotifyWorksWithoutFeatureFlag(t *testing.T) { db := setupNotificationTestDB(t) _ = db.AutoMigrate(&models.Setting{}) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -2410,7 +2413,7 @@ func TestTestProvider_GotifyWorksWithoutFeatureFlag(t *testing.T) { func TestTestProvider_WebhookWorksWithoutFeatureFlag(t *testing.T) { db := setupNotificationTestDB(t) _ = db.AutoMigrate(&models.Setting{}) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -2430,7 +2433,7 @@ func TestTestProvider_WebhookWorksWithoutFeatureFlag(t *testing.T) { func TestTestProvider_GotifyWorksWhenFlagExplicitlyFalse(t *testing.T) { db := setupNotificationTestDB(t) _ = db.AutoMigrate(&models.Setting{}) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Explicitly set feature flag to false db.Create(&models.Setting{Key: "feature.notifications.service.gotify.enabled", Value: "false"}) @@ -2454,7 +2457,7 @@ func TestTestProvider_GotifyWorksWhenFlagExplicitlyFalse(t *testing.T) { func TestTestProvider_WebhookWorksWhenFlagExplicitlyFalse(t *testing.T) { db := setupNotificationTestDB(t) _ = db.AutoMigrate(&models.Setting{}) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) // Explicitly set feature flag to false db.Create(&models.Setting{Key: "feature.notifications.service.webhook.enabled", Value: "false"}) @@ -2477,7 +2480,7 @@ func TestTestProvider_WebhookWorksWhenFlagExplicitlyFalse(t *testing.T) { func TestUpdateProvider_TypeMutationBlocked(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) existing := models.NotificationProvider{ ID: "prov-type-mut", @@ -2500,7 +2503,7 @@ func TestUpdateProvider_TypeMutationBlocked(t *testing.T) { func TestUpdateProvider_GotifyKeepsExistingToken(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) existing := models.NotificationProvider{ ID: "prov-gotify-token", @@ -2526,7 +2529,7 @@ func TestUpdateProvider_GotifyKeepsExistingToken(t *testing.T) { func TestGetFeatureFlagValue_FoundSetting(t *testing.T) { db := setupNotificationTestDB(t) require.NoError(t, db.AutoMigrate(&models.Setting{})) - svc := NewNotificationService(db) + svc := NewNotificationService(db, nil) tests := []struct { name string @@ -2550,3 +2553,237 @@ func TestGetFeatureFlagValue_FoundSetting(t *testing.T) { }) } } + +// --- mockMailService for dispatchEmail tests --- + +type mockMailService struct { + mu sync.Mutex + isConfigured bool + sendEmailErr error + calls []mockSendEmailCall +} + +type mockSendEmailCall struct { + to []string + subject string + body string +} + +func (m *mockMailService) IsConfigured() bool { return m.isConfigured } + +func (m *mockMailService) SendEmail(_ context.Context, to []string, subject, htmlBody string) error { + m.mu.Lock() + m.calls = append(m.calls, mockSendEmailCall{to: to, subject: subject, body: htmlBody}) + m.mu.Unlock() + return m.sendEmailErr +} + +func (m *mockMailService) callCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.calls) +} + +func (m *mockMailService) firstCall() mockSendEmailCall { + m.mu.Lock() + defer m.mu.Unlock() + return m.calls[0] +} + +func TestDispatchEmail_NilMailService(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + p := models.NotificationProvider{Name: "test-email", URL: "a@b.com", Type: "email"} + // Must not panic + svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message") +} + +func TestDispatchEmail_SMTPNotConfigured(t *testing.T) { + db := setupNotificationTestDB(t) + mock := &mockMailService{isConfigured: false} + svc := NewNotificationService(db, mock) + + p := models.NotificationProvider{Name: "test-email", URL: "a@b.com", Type: "email"} + svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message") + + assert.Empty(t, mock.calls) +} + +func TestDispatchEmail_EmptyRecipients(t *testing.T) { + db := setupNotificationTestDB(t) + mock := &mockMailService{isConfigured: true} + svc := NewNotificationService(db, mock) + + p := models.NotificationProvider{Name: "test-email", URL: " , , ", Type: "email"} + svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message") + + assert.Empty(t, mock.calls) +} + +func TestDispatchEmail_ValidSend(t *testing.T) { + db := setupNotificationTestDB(t) + mock := &mockMailService{isConfigured: true} + svc := NewNotificationService(db, mock) + + p := models.NotificationProvider{Name: "test-email", URL: "a@b.com, c@d.com", Type: "email"} + svc.dispatchEmail(context.Background(), p, "alert", "My Title", "My Message") + + require.Len(t, mock.calls, 1) + assert.Equal(t, []string{"a@b.com", "c@d.com"}, mock.calls[0].to) + assert.Equal(t, "[Charon Alert] My Title", mock.calls[0].subject) + assert.Contains(t, mock.calls[0].body, "My Title") + assert.Contains(t, mock.calls[0].body, "My Message") +} + +func TestDispatchEmail_SendError_Logged(t *testing.T) { + db := setupNotificationTestDB(t) + mock := &mockMailService{isConfigured: true, sendEmailErr: fmt.Errorf("smtp failure")} + svc := NewNotificationService(db, mock) + + p := models.NotificationProvider{Name: "test-email", URL: "a@b.com", Type: "email"} + // Must not panic even when SendEmail returns error + svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message") + + assert.Len(t, mock.calls, 1) +} + +func TestSendExternal_EmailProvider_Dispatches(t *testing.T) { + db := setupNotificationTestDB(t) + require.NoError(t, db.AutoMigrate(&models.Setting{})) + + mock := &mockMailService{isConfigured: true} + svc := NewNotificationService(db, mock) + + provider := models.NotificationProvider{ + Name: "email-provider", + Type: "email", + URL: "notify@example.com", + Enabled: true, + } + require.NoError(t, db.Create(&provider).Error) + + db.Create(&models.Setting{Key: notifications.FlagEmailServiceEnabled, Value: "true"}) + + svc.SendExternal(context.Background(), "test", "Title", "Body", nil) + + // Allow goroutine to run + require.Eventually(t, func() bool { return mock.callCount() > 0 }, 2*time.Second, 10*time.Millisecond) + assert.Equal(t, []string{"notify@example.com"}, mock.firstCall().to) +} + +func TestSendExternal_EmailProvider_FlagDisabled(t *testing.T) { + db := setupNotificationTestDB(t) + require.NoError(t, db.AutoMigrate(&models.Setting{})) + + mock := &mockMailService{isConfigured: true} + svc := NewNotificationService(db, mock) + + provider := models.NotificationProvider{ + Name: "email-off", + Type: "email", + URL: "notify@example.com", + Enabled: true, + } + require.NoError(t, db.Create(&provider).Error) + + db.Create(&models.Setting{Key: notifications.FlagEmailServiceEnabled, Value: "false"}) + + svc.SendExternal(context.Background(), "test", "Title", "Body", nil) + + time.Sleep(50 * time.Millisecond) + assert.Zero(t, mock.callCount()) +} + +func TestDispatchEmail_InvalidRecipient(t *testing.T) { + db := setupNotificationTestDB(t) + mock := &mockMailService{isConfigured: true, sendEmailErr: ErrInvalidRecipient} + svc := NewNotificationService(db, mock) + + p := models.NotificationProvider{Name: "test-email", URL: "not-an-email", Type: "email"} + // dispatchEmail will call SendEmail; the mock returns ErrInvalidRecipient — must not panic + svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message") + + // SendEmail was called once (validation happens inside real SendEmail, mock just returns the error) + assert.Len(t, mock.calls, 1) +} + +func TestDispatchEmail_TooManyRecipients(t *testing.T) { + db := setupNotificationTestDB(t) + mock := &mockMailService{isConfigured: true, sendEmailErr: ErrTooManyRecipients} + svc := NewNotificationService(db, mock) + + recipients := make([]string, 21) + for i := range recipients { + recipients[i] = fmt.Sprintf("user%d@example.com", i) + } + p := models.NotificationProvider{Name: "test-email", URL: strings.Join(recipients, ","), Type: "email"} + // dispatchEmail passes all recipients to SendEmail; mock returns ErrTooManyRecipients — must not panic + svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message") + + assert.Len(t, mock.calls, 1) +} + +func TestDispatchEmail_HeaderInjectionRecipient(t *testing.T) { + db := setupNotificationTestDB(t) + mock := &mockMailService{isConfigured: true, sendEmailErr: ErrInvalidRecipient} + svc := NewNotificationService(db, mock) + + p := models.NotificationProvider{Name: "test-email", URL: "bad\r\naddr@test.com", Type: "email"} + // The recipient contains CR/LF; dispatchEmail trims + splits but passes to SendEmail which rejects — must not panic + svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message") + + assert.Len(t, mock.calls, 1) +} + +func TestSendExternal_EmailProviderDoesNotCallSendJSONPayload(t *testing.T) { + db := setupNotificationTestDB(t) + require.NoError(t, db.AutoMigrate(&models.Setting{})) + + mock := &mockMailService{isConfigured: true} + svc := NewNotificationService(db, mock) + + // Track any JSON payload call via the webhook hook + jsonPayloadCalled := false + origDo := webhookDoRequestFunc + defer func() { webhookDoRequestFunc = origDo }() + webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { + jsonPayloadCalled = true + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil + } + + provider := models.NotificationProvider{ + Name: "email-no-http", + Type: "email", + URL: "notify@example.com", + Enabled: true, + } + require.NoError(t, db.Create(&provider).Error) + db.Create(&models.Setting{Key: notifications.FlagEmailServiceEnabled, Value: "true"}) + + svc.SendExternal(context.Background(), "test", "Title", "Body", nil) + require.Eventually(t, func() bool { return mock.callCount() > 0 }, 2*time.Second, 10*time.Millisecond) + + assert.False(t, jsonPayloadCalled, "email provider must not trigger HTTP JSON payload path") +} + +func TestDispatchEmail_XSSPayload_BodySanitized(t *testing.T) { + db := setupNotificationTestDB(t) + mock := &mockMailService{isConfigured: true} + svc := NewNotificationService(db, mock) + + xssTitle := `` + xssMessage := `` + + p := models.NotificationProvider{Name: "test-email", URL: "a@b.com", Type: "email"} + svc.dispatchEmail(context.Background(), p, "alert", xssTitle, xssMessage) + + require.Len(t, mock.calls, 1) + body := mock.calls[0].body + // Raw script tags must not appear — they must be escaped. + assert.NotContains(t, body, "