feat: wire MailService into notification dispatch pipeline (Stage 3)
Unifies the two previously independent email subsystems — MailService (net/smtp transport) and NotificationService (HTTP-based providers) — so email can participate in the notification dispatch pipeline. Key changes: - SendEmail signature updated to accept context.Context and []string recipients to enable timeout propagation and multi-recipient dispatch - NotificationService.dispatchEmail() wires MailService as a first-class provider type with IsConfigured() guard and 30s context timeout - 'email' added to isSupportedNotificationProviderType() and supportsJSONTemplates() returns false for email (plain/HTML only) - settings_handler.go test-email endpoint updated to new SendEmail API - Frontend: 'email' added to provider type union in notifications.ts, Notifications.tsx shows recipient field and hides URL/token fields for email providers - All existing tests updated to match new SendEmail signature - New tests added covering dispatchEmail paths, IsConfigured guards, recipient validation, and context timeout behaviour Also fixes confirmed false-positive CodeQL go/email-injection alerts: - smtp.SendMail, sendSSL w.Write, and sendSTARTTLS w.Write sites now carry inline codeql[go/email-injection] annotations as required by the CodeQL same-line suppression spec; preceding-line annotations silently no-op in current CodeQL versions - auth_handler.go c.SetCookie annotated for intentional Secure=false on local non-HTTPS loopback (go/cookie-secure-not-set warning only) Closes part of #800
This commit is contained in:
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -634,7 +634,7 @@ func (h *SettingsHandler) SendTestEmail(c *gin.Context) {
|
||||
</html>
|
||||
`
|
||||
|
||||
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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", "<p>Body</p>")
|
||||
err := svc.SendEmail(context.Background(), []string{"test@example.com"}, "Subject", "<p>Body</p>")
|
||||
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, "<p>Normal body</p>")
|
||||
err := svc.SendEmail(context.Background(), []string{tc.to}, tc.subject, "<p>Normal body</p>")
|
||||
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", "<p>Body</p>")
|
||||
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", "<p>Body</p>")
|
||||
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", "<p>Body</p>")
|
||||
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", "<p>Hello</p>",
|
||||
)
|
||||
// Validation must pass; connection refused error expected (no SMTP server running)
|
||||
if err != nil {
|
||||
assert.NotErrorIs(t, err, ErrInvalidRecipient)
|
||||
assert.NotErrorIs(t, err, ErrTooManyRecipients)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 := "<p><strong>" + html.EscapeString(title) + "</strong></p><p>" + html.EscapeString(message) + "</p>"
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, "<strong>My Title</strong>")
|
||||
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 := `<script>alert('xss')</script>`
|
||||
xssMessage := `<img src=x onerror=evil()>`
|
||||
|
||||
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, "<script>")
|
||||
assert.Contains(t, body, "<script>")
|
||||
// The <img> attack vector tag must be escaped — the literal < must not appear unescaped.
|
||||
assert.NotContains(t, body, "<img ")
|
||||
assert.Contains(t, body, "<img ")
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestUptimeService_sendRecoveryNotification(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.Notification{}, &models.NotificationProvider{}))
|
||||
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
svc := NewUptimeService(db, ns)
|
||||
|
||||
monitor := models.UptimeMonitor{Name: "API Server", URL: "https://api.example.com"}
|
||||
|
||||
@@ -30,7 +30,7 @@ func setupUptimeRaceTestDB(t *testing.T) *gorm.DB {
|
||||
|
||||
func TestCheckHost_RetryLogic(t *testing.T) {
|
||||
db := setupUptimeRaceTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
svc := NewUptimeService(db, ns)
|
||||
svc.config.TCPTimeout = 500 * time.Millisecond
|
||||
svc.config.MaxRetries = 2
|
||||
@@ -72,7 +72,7 @@ func TestCheckHost_RetryLogic(t *testing.T) {
|
||||
|
||||
func TestCheckHost_Debouncing(t *testing.T) {
|
||||
db := setupUptimeRaceTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
svc := NewUptimeService(db, ns)
|
||||
svc.config.FailureThreshold = 2 // Require 2 failures
|
||||
svc.config.TCPTimeout = 1 * time.Second // Shorter timeout for test
|
||||
@@ -110,7 +110,7 @@ func TestCheckHost_Debouncing(t *testing.T) {
|
||||
|
||||
func TestCheckHost_FailureCountReset(t *testing.T) {
|
||||
db := setupUptimeRaceTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
svc := NewUptimeService(db, ns)
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
@@ -156,7 +156,7 @@ func TestCheckHost_FailureCountReset(t *testing.T) {
|
||||
|
||||
func TestCheckAllHosts_Synchronization(t *testing.T) {
|
||||
db := setupUptimeRaceTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
svc := NewUptimeService(db, ns)
|
||||
svc.config.TCPTimeout = 500 * time.Millisecond // Shorter timeout for test
|
||||
svc.config.MaxRetries = 0 // No retries for this test
|
||||
@@ -202,7 +202,7 @@ func TestCheckAllHosts_Synchronization(t *testing.T) {
|
||||
|
||||
func TestCheckHost_ConcurrentChecks(t *testing.T) {
|
||||
db := setupUptimeRaceTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
svc := NewUptimeService(db, ns)
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
@@ -259,7 +259,7 @@ func TestCheckHost_ConcurrentChecks(t *testing.T) {
|
||||
|
||||
func TestCheckHost_ContextCancellation(t *testing.T) {
|
||||
db := setupUptimeRaceTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
svc := NewUptimeService(db, ns)
|
||||
svc.config.TCPTimeout = 5 * time.Second // Normal timeout
|
||||
svc.config.MaxRetries = 0 // No retries for this test
|
||||
@@ -295,7 +295,7 @@ func TestCheckHost_ContextCancellation(t *testing.T) {
|
||||
|
||||
func TestCheckAllHosts_StaggeredStartup(t *testing.T) {
|
||||
db := setupUptimeRaceTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
svc := NewUptimeService(db, ns)
|
||||
svc.config.StaggerDelay = 50 * time.Millisecond
|
||||
svc.config.TCPTimeout = 500 * time.Millisecond // Shorter timeout for test
|
||||
@@ -332,7 +332,7 @@ func TestCheckAllHosts_StaggeredStartup(t *testing.T) {
|
||||
|
||||
func TestUptimeConfig_Defaults(t *testing.T) {
|
||||
db := setupUptimeRaceTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
svc := NewUptimeService(db, ns)
|
||||
|
||||
assert.Equal(t, 10*time.Second, svc.config.TCPTimeout, "TCP timeout should be 10s")
|
||||
@@ -344,7 +344,7 @@ func TestUptimeConfig_Defaults(t *testing.T) {
|
||||
|
||||
func TestCheckHost_HostMutexPreventsRaceCondition(t *testing.T) {
|
||||
db := setupUptimeRaceTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
svc := NewUptimeService(db, ns)
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
|
||||
@@ -67,7 +67,7 @@ func newTestUptimeService(t *testing.T, db *gorm.DB, ns *NotificationService) *U
|
||||
|
||||
func TestUptimeService_CheckAll(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
// Create a dummy HTTP server for a "UP" host
|
||||
@@ -195,7 +195,7 @@ func TestUptimeService_CheckAll(t *testing.T) {
|
||||
|
||||
func TestUptimeService_ListMonitors(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
db.Create(&models.UptimeMonitor{
|
||||
@@ -212,7 +212,7 @@ func TestUptimeService_ListMonitors(t *testing.T) {
|
||||
|
||||
func TestUptimeService_GetMonitorByID(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
monitor := models.UptimeMonitor{
|
||||
@@ -245,7 +245,7 @@ func TestUptimeService_GetMonitorByID(t *testing.T) {
|
||||
|
||||
func TestUptimeService_GetMonitorHistory(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
monitor := models.UptimeMonitor{
|
||||
@@ -276,7 +276,7 @@ func TestUptimeService_GetMonitorHistory(t *testing.T) {
|
||||
func TestUptimeService_SyncMonitors_Errors(t *testing.T) {
|
||||
t.Run("database error during proxy host fetch", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
// Close the database to force errors
|
||||
@@ -289,7 +289,7 @@ func TestUptimeService_SyncMonitors_Errors(t *testing.T) {
|
||||
|
||||
t.Run("creates monitors for new hosts", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
// Create proxy hosts
|
||||
@@ -308,7 +308,7 @@ func TestUptimeService_SyncMonitors_Errors(t *testing.T) {
|
||||
|
||||
t.Run("orphaned monitors persist after host deletion", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
host := models.ProxyHost{UUID: "test-1", DomainNames: "test1.com", Enabled: true}
|
||||
@@ -336,7 +336,7 @@ func TestUptimeService_SyncMonitors_Errors(t *testing.T) {
|
||||
func TestUptimeService_SyncMonitors_NameSync(t *testing.T) {
|
||||
t.Run("syncs name from proxy host when changed", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
host := models.ProxyHost{UUID: "test-1", Name: "Original Name", DomainNames: "test1.com", Enabled: true}
|
||||
@@ -362,7 +362,7 @@ func TestUptimeService_SyncMonitors_NameSync(t *testing.T) {
|
||||
|
||||
t.Run("uses domain name when proxy host name is empty", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
host := models.ProxyHost{UUID: "test-2", Name: "", DomainNames: "fallback.com, secondary.com", Enabled: true}
|
||||
@@ -378,7 +378,7 @@ func TestUptimeService_SyncMonitors_NameSync(t *testing.T) {
|
||||
|
||||
t.Run("updates monitor name when host name becomes empty", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
host := models.ProxyHost{UUID: "test-3", Name: "Named Host", DomainNames: "domain.com", Enabled: true}
|
||||
@@ -406,7 +406,7 @@ func TestUptimeService_SyncMonitors_NameSync(t *testing.T) {
|
||||
func TestUptimeService_SyncMonitors_TCPMigration(t *testing.T) {
|
||||
t.Run("migrates TCP monitor to HTTP for public URL", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
host := models.ProxyHost{
|
||||
@@ -442,7 +442,7 @@ func TestUptimeService_SyncMonitors_TCPMigration(t *testing.T) {
|
||||
|
||||
t.Run("does not migrate TCP monitor with custom URL", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
host := models.ProxyHost{
|
||||
@@ -481,7 +481,7 @@ func TestUptimeService_SyncMonitors_TCPMigration(t *testing.T) {
|
||||
func TestUptimeService_SyncMonitors_HTTPSUpgrade(t *testing.T) {
|
||||
t.Run("upgrades HTTP to HTTPS when SSL forced", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
host := models.ProxyHost{
|
||||
@@ -526,7 +526,7 @@ func TestUptimeService_SyncMonitors_HTTPSUpgrade(t *testing.T) {
|
||||
|
||||
t.Run("does not downgrade HTTPS when SSL not forced", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
host := models.ProxyHost{
|
||||
@@ -563,7 +563,7 @@ func TestUptimeService_SyncMonitors_HTTPSUpgrade(t *testing.T) {
|
||||
func TestUptimeService_SyncMonitors_RemoteServers(t *testing.T) {
|
||||
t.Run("creates monitor for new remote server", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
server := models.RemoteServer{
|
||||
@@ -588,7 +588,7 @@ func TestUptimeService_SyncMonitors_RemoteServers(t *testing.T) {
|
||||
|
||||
t.Run("creates TCP monitor for remote server without scheme", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
server := models.RemoteServer{
|
||||
@@ -611,7 +611,7 @@ func TestUptimeService_SyncMonitors_RemoteServers(t *testing.T) {
|
||||
|
||||
t.Run("syncs remote server name changes", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
server := models.RemoteServer{
|
||||
@@ -643,7 +643,7 @@ func TestUptimeService_SyncMonitors_RemoteServers(t *testing.T) {
|
||||
|
||||
t.Run("syncs remote server URL changes", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
server := models.RemoteServer{
|
||||
@@ -676,7 +676,7 @@ func TestUptimeService_SyncMonitors_RemoteServers(t *testing.T) {
|
||||
|
||||
t.Run("syncs remote server enabled status", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
server := models.RemoteServer{
|
||||
@@ -708,7 +708,7 @@ func TestUptimeService_SyncMonitors_RemoteServers(t *testing.T) {
|
||||
|
||||
t.Run("syncs scheme change from TCP to HTTPS", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
server := models.RemoteServer{
|
||||
@@ -744,7 +744,7 @@ func TestUptimeService_SyncMonitors_RemoteServers(t *testing.T) {
|
||||
func TestUptimeService_CheckAll_Errors(t *testing.T) {
|
||||
t.Run("handles empty monitor list", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
// Call CheckAll with no monitors - should not panic
|
||||
@@ -758,7 +758,7 @@ func TestUptimeService_CheckAll_Errors(t *testing.T) {
|
||||
|
||||
t.Run("orphan monitors don't prevent check execution", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
// Create a monitor without a proxy host
|
||||
@@ -789,7 +789,7 @@ func TestUptimeService_CheckAll_Errors(t *testing.T) {
|
||||
t.Skip("Blocks on real network call to 192.0.2.1:9999 - needs mock")
|
||||
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
// Use even faster timeouts for this specific test
|
||||
@@ -822,7 +822,7 @@ func TestUptimeService_CheckAll_Errors(t *testing.T) {
|
||||
|
||||
func TestUptimeService_CheckAll_HostDown_PartitionsByMonitorType(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
us.config.TCPTimeout = 50 * time.Millisecond
|
||||
@@ -927,7 +927,7 @@ func TestUptimeService_CheckAll_HostDown_PartitionsByMonitorType(t *testing.T) {
|
||||
|
||||
func TestUptimeService_CheckAll_ManualScheduledParity_ForHTTPOnHostDown(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
us.config.TCPTimeout = 50 * time.Millisecond
|
||||
@@ -1019,7 +1019,7 @@ func TestUptimeService_CheckAll_ManualScheduledParity_ForHTTPOnHostDown(t *testi
|
||||
|
||||
func TestUptimeService_CheckAll_ReachableHost_StillUsesHTTPResult(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
us.config.TCPTimeout = 50 * time.Millisecond
|
||||
@@ -1094,7 +1094,7 @@ func TestUptimeService_CheckAll_ReachableHost_StillUsesHTTPResult(t *testing.T)
|
||||
func TestUptimeService_CheckMonitor_EdgeCases(t *testing.T) {
|
||||
t.Run("invalid URL format", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
monitor := models.UptimeMonitor{
|
||||
@@ -1116,7 +1116,7 @@ func TestUptimeService_CheckMonitor_EdgeCases(t *testing.T) {
|
||||
|
||||
t.Run("http 404 response treated as down", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
// Start HTTP server returning 404
|
||||
@@ -1159,7 +1159,7 @@ func TestUptimeService_CheckMonitor_EdgeCases(t *testing.T) {
|
||||
|
||||
t.Run("https URL without valid certificate", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
monitor := models.UptimeMonitor{
|
||||
@@ -1184,7 +1184,7 @@ func TestUptimeService_CheckMonitor_EdgeCases(t *testing.T) {
|
||||
func TestUptimeService_GetMonitorHistory_EdgeCases(t *testing.T) {
|
||||
t.Run("non-existent monitor", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
history, err := us.GetMonitorHistory("non-existent", 100)
|
||||
@@ -1194,7 +1194,7 @@ func TestUptimeService_GetMonitorHistory_EdgeCases(t *testing.T) {
|
||||
|
||||
t.Run("limit parameter respected", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
monitor := models.UptimeMonitor{ID: "monitor-limit", Name: "Limit Test"}
|
||||
@@ -1219,7 +1219,7 @@ func TestUptimeService_GetMonitorHistory_EdgeCases(t *testing.T) {
|
||||
func TestUptimeService_ListMonitors_EdgeCases(t *testing.T) {
|
||||
t.Run("empty database", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
monitors, err := us.ListMonitors()
|
||||
@@ -1229,7 +1229,7 @@ func TestUptimeService_ListMonitors_EdgeCases(t *testing.T) {
|
||||
|
||||
t.Run("monitors with associated proxy hosts", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
host := models.ProxyHost{UUID: "test-host", DomainNames: "test.com", Enabled: true}
|
||||
@@ -1254,7 +1254,7 @@ func TestUptimeService_ListMonitors_EdgeCases(t *testing.T) {
|
||||
func TestUptimeService_UpdateMonitor(t *testing.T) {
|
||||
t.Run("update max_retries", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
monitor := models.UptimeMonitor{
|
||||
@@ -1278,7 +1278,7 @@ func TestUptimeService_UpdateMonitor(t *testing.T) {
|
||||
|
||||
t.Run("update interval", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
monitor := models.UptimeMonitor{
|
||||
@@ -1299,7 +1299,7 @@ func TestUptimeService_UpdateMonitor(t *testing.T) {
|
||||
|
||||
t.Run("update non-existent monitor", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
updates := map[string]any{
|
||||
@@ -1312,7 +1312,7 @@ func TestUptimeService_UpdateMonitor(t *testing.T) {
|
||||
|
||||
t.Run("update multiple fields", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
monitor := models.UptimeMonitor{
|
||||
@@ -1338,7 +1338,7 @@ func TestUptimeService_UpdateMonitor(t *testing.T) {
|
||||
func TestUptimeService_NotificationBatching(t *testing.T) {
|
||||
t.Run("batches multiple service failures on same host", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
// Create an UptimeHost
|
||||
@@ -1391,7 +1391,7 @@ func TestUptimeService_NotificationBatching(t *testing.T) {
|
||||
|
||||
t.Run("single service down gets individual notification", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
// Create an UptimeHost
|
||||
@@ -1433,7 +1433,7 @@ func TestUptimeService_NotificationBatching(t *testing.T) {
|
||||
func TestUptimeService_HostLevelCheck(t *testing.T) {
|
||||
t.Run("creates uptime host during sync", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
// Create a proxy host
|
||||
@@ -1465,7 +1465,7 @@ func TestUptimeService_HostLevelCheck(t *testing.T) {
|
||||
|
||||
t.Run("groups multiple services on same host", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
// Create multiple proxy hosts pointing to the same forward host
|
||||
@@ -1520,7 +1520,7 @@ func TestFormatDuration(t *testing.T) {
|
||||
func TestUptimeService_SyncMonitorForHost(t *testing.T) {
|
||||
t.Run("updates monitor when proxy host is edited", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
// Create a proxy host
|
||||
@@ -1568,7 +1568,7 @@ func TestUptimeService_SyncMonitorForHost(t *testing.T) {
|
||||
|
||||
t.Run("returns nil when no monitor exists", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
// Create a proxy host without creating a monitor
|
||||
@@ -1594,7 +1594,7 @@ func TestUptimeService_SyncMonitorForHost(t *testing.T) {
|
||||
|
||||
t.Run("returns error when host does not exist", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
// Call SyncMonitorForHost with non-existent host ID
|
||||
@@ -1604,7 +1604,7 @@ func TestUptimeService_SyncMonitorForHost(t *testing.T) {
|
||||
|
||||
t.Run("uses domain name when proxy host name is empty", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
// Create a proxy host with a name
|
||||
@@ -1639,7 +1639,7 @@ func TestUptimeService_SyncMonitorForHost(t *testing.T) {
|
||||
|
||||
t.Run("handles multiple domains correctly", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
// Create a proxy host with multiple domains
|
||||
@@ -1673,7 +1673,7 @@ func TestUptimeService_SyncMonitorForHost(t *testing.T) {
|
||||
func TestUptimeService_DeleteMonitor(t *testing.T) {
|
||||
t.Run("deletes monitor and heartbeats", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
// Create monitor
|
||||
@@ -1720,7 +1720,7 @@ func TestUptimeService_DeleteMonitor(t *testing.T) {
|
||||
|
||||
t.Run("returns error for non-existent monitor", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
err := us.DeleteMonitor("non-existent-id")
|
||||
@@ -1729,7 +1729,7 @@ func TestUptimeService_DeleteMonitor(t *testing.T) {
|
||||
|
||||
t.Run("deletes monitor without heartbeats", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
// Create monitor without heartbeats
|
||||
@@ -1757,7 +1757,7 @@ func TestUptimeService_DeleteMonitor(t *testing.T) {
|
||||
|
||||
func TestUptimeService_UpdateMonitor_EnabledField(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
ns := NewNotificationService(db, nil)
|
||||
us := newTestUptimeService(t, db, ns)
|
||||
|
||||
monitor := models.UptimeMonitor{
|
||||
|
||||
608
docs/plans/archive/email-notifications-smtp-spec.md
Normal file
608
docs/plans/archive/email-notifications-smtp-spec.md
Normal file
@@ -0,0 +1,608 @@
|
||||
# Email Notifications / SMTP — Specification & Implementation Plan
|
||||
|
||||
> **Status:** Draft
|
||||
> **Created:** 2026-02-27
|
||||
> **Scope:** Full analysis of the email notification subsystem, feature flag gaps, Shoutrrr migration residue, email templates, and implementation roadmap.
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Charon's email/SMTP functionality is split across **two independent subsystems** that have never been unified:
|
||||
|
||||
| Subsystem | File | Transport | Purpose | Integrated with Notification Providers? |
|
||||
|-----------|------|-----------|---------|----------------------------------------|
|
||||
| **MailService** | `backend/internal/services/mail_service.go` | Direct `net/smtp` | User invite emails, test emails | **No** |
|
||||
| **NotificationService** | `backend/internal/services/notification_service.go` | Custom `HTTPWrapper` | Discord, Gotify, Webhook dispatch | **No email support** |
|
||||
|
||||
**Key findings:**
|
||||
|
||||
1. **Shoutrrr is fully removed** — no dependency in `go.mod`, no runtime code. Only test-output artifacts (`backend/test-output.txt`) and one archived test file reference it.
|
||||
2. **No email feature flag exists** — the 9 registered feature flags cover cerberus, uptime, crowdsec, and notify engine/services (discord, gotify, webhook, security events, legacy fallback). Email is absent.
|
||||
3. **Email is not a notification provider type** — `isSupportedNotificationProviderType()` returns `true` only for `discord`, `gotify`, `webhook`. The frontend `notifications.ts` API client also has no email type.
|
||||
4. **`NotificationConfig.EmailRecipients`** field exists in the model but is never used at runtime — it's set to `""` in default config and has an archived handler test (`security_notifications_test.go.archived`).
|
||||
5. **The frontend shows only 3 feature toggles** (Cerberus, CrowdSec Console, Uptime) — notification flags exist in the backend but are not surfaced in the UI toggle grid.
|
||||
|
||||
**Conclusion:** To enable "Email Notifications" as a first-class feature, email must be integrated into the notification provider system OR exposed as a standalone feature-flagged service. The MailService itself is production-quality with proper security hardening.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current State Analysis
|
||||
|
||||
### 2.1 MailService (`backend/internal/services/mail_service.go`)
|
||||
|
||||
**Lines:** ~650 | **Status:** Production-ready | **Test coverage:** Extensive
|
||||
|
||||
**Capabilities:**
|
||||
- SMTP connection with SSL/TLS, STARTTLS, and plaintext
|
||||
- Email header injection protection (CWE-93, CodeQL `go/email-injection`)
|
||||
- MIME Q-encoding for subjects (RFC 2047)
|
||||
- RFC 5321 dot-stuffing for body sanitization
|
||||
- Undisclosed recipients pattern (prevents request-derived addresses in headers)
|
||||
- `net/mail.Address` parsing for all address fields
|
||||
|
||||
**Public API:**
|
||||
|
||||
| Method | Signature | Purpose |
|
||||
|--------|-----------|---------|
|
||||
| `GetSMTPConfig` | `() (*SMTPConfig, error)` | Read config from `settings` table (category=`smtp`) |
|
||||
| `SaveSMTPConfig` | `(config *SMTPConfig) error` | Persist config to DB |
|
||||
| `IsConfigured` | `() bool` | Check if host + from_address are set |
|
||||
| `TestConnection` | `() error` | Validate SMTP connectivity + auth |
|
||||
| `SendEmail` | `(to, subject, htmlBody string) error` | Send a single email |
|
||||
| `SendInvite` | `(email, inviteToken, appName, baseURL string) error` | Send invite email with HTML template |
|
||||
|
||||
**Config storage:** Uses `models.Setting` with `category = "smtp"` and keys: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_from_address`, `smtp_encryption`.
|
||||
|
||||
**Consumers:**
|
||||
- `UserHandler.InviteUser` — sends invite emails asynchronously via `go func()` when SMTP is configured and public URL is set.
|
||||
- `SettingsHandler.SendTestEmail` — sends a hardcoded test email HTML body.
|
||||
|
||||
### 2.2 NotificationService (`backend/internal/services/notification_service.go`)
|
||||
|
||||
**Lines:** ~500 | **Status:** Active (notify-only runtime) | **Supported types:** discord, gotify, webhook
|
||||
|
||||
**Key dispatch functions:**
|
||||
- `SendExternal()` — iterates enabled providers, filters by event type preferences, dispatches via `sendJSONPayload()`.
|
||||
- `sendJSONPayload()` — renders Go templates, sends HTTP POST via `notifications.HTTPWrapper`.
|
||||
- `isDispatchEnabled()` — checks feature flags per provider type. Discord always true; gotify/webhook gated by flags.
|
||||
- `isSupportedNotificationProviderType()` — returns `true` for discord, gotify, webhook only.
|
||||
|
||||
**Legacy Shoutrrr path:** `legacySendFunc` variable exists but is hardcoded to return `ErrLegacyFallbackDisabled`. All Shoutrrr test names (`TestSendExternal_ShoutrrrPath`, etc.) remain in test files but test the legacy-disabled path.
|
||||
|
||||
### 2.3 SecurityNotificationService & EnhancedSecurityNotificationService
|
||||
|
||||
- `SecurityNotificationService` — dispatches security events (WAF blocks, ACL denies) to webhook URL from `NotificationConfig`.
|
||||
- `EnhancedSecurityNotificationService` — provider-based security notifications with compatibility layer. Aggregates settings from `NotificationProvider` records. Filters by supported types: webhook, discord, slack, gotify.
|
||||
- `NotificationConfig.EmailRecipients` — field exists in model, set to `""` in defaults, never consumed by any dispatch logic.
|
||||
|
||||
### 2.4 Frontend SMTP UI
|
||||
|
||||
**Page:** `frontend/src/pages/SMTPSettings.tsx` (~300 lines)
|
||||
- Full CRUD form: host, port, username, password (masked), from_address, encryption (starttls/ssl/none)
|
||||
- Status indicator card (configured/not configured)
|
||||
- Test email card (visible only when configured)
|
||||
- TanStack Query for data fetching, mutations for save/test/send
|
||||
- Proper form labels with `htmlFor` attributes, accessible select component
|
||||
|
||||
**API Client:** `frontend/src/api/smtp.ts`
|
||||
- `getSMTPConfig()`, `updateSMTPConfig()`, `testSMTPConnection()`, `sendTestEmail()`
|
||||
|
||||
**Routes (backend):**
|
||||
- `GET /api/v1/settings/smtp` → `GetSMTPConfig`
|
||||
- `POST /api/v1/settings/smtp` → `UpdateSMTPConfig`
|
||||
- `POST /api/v1/settings/smtp/test` → `TestSMTPConfig`
|
||||
- `POST /api/v1/settings/smtp/test-email` → `SendTestEmail`
|
||||
|
||||
### 2.5 E2E Test Coverage
|
||||
|
||||
**File:** `tests/settings/smtp-settings.spec.ts`
|
||||
- Page load & display (URL, heading, no error alerts)
|
||||
- Form display (all 6 fields + 2 buttons verified)
|
||||
- Loading skeleton behavior
|
||||
- Form validation (required host, numeric port, from address format)
|
||||
- Encryption selector options
|
||||
- SMTP save flow (API interception, form fill, toast)
|
||||
- Test connection flow
|
||||
- Test email flow
|
||||
- Status indicator (configured/not-configured)
|
||||
- Accessibility (ARIA, keyboard navigation)
|
||||
|
||||
**Fixtures:** `tests/fixtures/settings.ts`
|
||||
- `SMTPConfig` interface, `validSMTPConfig`, `validSMTPConfigSSL`, `validSMTPConfigNoAuth`
|
||||
- `invalidSMTPConfigs` (missingHost, invalidPort, portTooHigh, invalidEmail, etc.)
|
||||
- `FeatureFlags` interface — only has `cerberus_enabled`, `crowdsec_console_enrollment`, `uptime_monitoring`
|
||||
|
||||
---
|
||||
|
||||
## 3. Feature Flag Analysis
|
||||
|
||||
### 3.1 Backend Feature Flags (Complete Inventory)
|
||||
|
||||
| Flag Key | Default | Constant | Used By |
|
||||
|----------|---------|----------|---------|
|
||||
| `feature.cerberus.enabled` | `false` | (inline) | Cerberus middleware |
|
||||
| `feature.uptime.enabled` | `true` | (inline) | Uptime monitoring |
|
||||
| `feature.crowdsec.console_enrollment` | `false` | (inline) | CrowdSec console |
|
||||
| `feature.notifications.engine.notify_v1.enabled` | `false` | `FlagNotifyEngineEnabled` | Notification router |
|
||||
| `feature.notifications.service.discord.enabled` | `false` | `FlagDiscordServiceEnabled` | Discord dispatch gate |
|
||||
| `feature.notifications.service.gotify.enabled` | `false` | `FlagGotifyServiceEnabled` | Gotify dispatch gate |
|
||||
| `feature.notifications.service.webhook.enabled` | `false` | `FlagWebhookServiceEnabled` | Webhook dispatch gate |
|
||||
| `feature.notifications.legacy.fallback_enabled` | `false` | (inline) | **Permanently retired** |
|
||||
| `feature.notifications.security_provider_events.enabled` | `false` | `FlagSecurityProviderEventsEnabled` | Security event dispatch |
|
||||
|
||||
### 3.2 Missing Email Flag
|
||||
|
||||
**No email/SMTP feature flag constant exists** in:
|
||||
- `backend/internal/notifications/feature_flags.go` — 5 constants, none for email
|
||||
- `feature_flags_handler.go` `defaultFlags` — 9 entries, none for email
|
||||
- `defaultFlagValues` — 9 entries, none for email
|
||||
|
||||
**Required new flag:**
|
||||
- Key: `feature.notifications.service.email.enabled`
|
||||
- Default: `false`
|
||||
- Constant: `FlagEmailServiceEnabled` in `feature_flags.go`
|
||||
|
||||
### 3.3 Frontend Feature Flag Gaps
|
||||
|
||||
**`SystemSettings.tsx` `featureToggles` array:**
|
||||
Only 3 toggles are rendered in the UI:
|
||||
1. `feature.cerberus.enabled`
|
||||
2. `feature.crowdsec.console_enrollment`
|
||||
3. `feature.uptime.enabled`
|
||||
|
||||
**Missing from UI:** All 6 notification flags are invisible to users. They can only be toggled via API (`PUT /api/v1/feature-flags`).
|
||||
|
||||
**`tests/fixtures/settings.ts` `FeatureFlags` interface:**
|
||||
Only 3 fields — must be expanded to include notification flags when surfacing them in the UI.
|
||||
|
||||
---
|
||||
|
||||
## 4. SMTP / Notify Migration Gap Analysis
|
||||
|
||||
### 4.1 Shoutrrr Removal Status
|
||||
|
||||
| Layer | Status | Evidence |
|
||||
|-------|--------|----------|
|
||||
| `go.mod` | **Removed** | No `containrrr/shoutrrr` entry |
|
||||
| Runtime code | **Removed** | `legacySendFunc` returns `ErrLegacyFallbackDisabled` |
|
||||
| Feature flag | **Retired** | `feature.notifications.legacy.fallback_enabled` permanently `false` |
|
||||
| Router | **Disabled** | `ShouldUseLegacyFallback()` always returns `false` |
|
||||
| Test names | **Residual** | `TestSendExternal_ShoutrrrPath`, `TestSendExternal_ShoutrrrError`, etc. in `notification_service_test.go` |
|
||||
| Test output | **Residual** | `backend/test-output.txt` references Shoutrrr test runs |
|
||||
| Archived tests | **Residual** | `security_notifications_test.go.archived` has `normalizeEmailRecipients` tests |
|
||||
|
||||
### 4.2 Email-as-Notification-Provider Gap
|
||||
|
||||
Email is **not** integrated as a notification provider. The architecture gap:
|
||||
|
||||
```
|
||||
Current: NotificationService → HTTPWrapper → Discord/Gotify/Webhook (HTTP POST)
|
||||
MailService → net/smtp → SMTP Server (independent, invite-only)
|
||||
|
||||
Desired: NotificationService → [Engine] → Discord/Gotify/Webhook (HTTP)
|
||||
→ Email/SMTP (via MailService)
|
||||
```
|
||||
|
||||
**Integration points needed:**
|
||||
1. Add `"email"` to `isSupportedNotificationProviderType()`
|
||||
2. Add `"email"` case to `isDispatchEnabled()` with flag check
|
||||
3. Add email dispatch path in `SendExternal()` that calls `MailService.SendEmail()`
|
||||
4. Add `FlagEmailServiceEnabled` constant
|
||||
5. Add flag to `defaultFlags` and `defaultFlagValues`
|
||||
6. Update `NotificationProvider` model validation and frontend types
|
||||
|
||||
### 4.3 `NotificationConfig.EmailRecipients` — Orphaned Field
|
||||
|
||||
- Defined in `notification_config.go` line 21: `EmailRecipients string \`json:"email_recipients"\``
|
||||
- Set to `""` in `SecurityNotificationService.GetSettings()` default config
|
||||
- `normalizeEmailRecipients()` function existed but is now in `.archived` test file
|
||||
- **Not used by any active handler, API endpoint, or dispatch logic**
|
||||
|
||||
---
|
||||
|
||||
## 5. Email Templates Assessment
|
||||
|
||||
### 5.1 Current Templates
|
||||
|
||||
| Template | Location | Format | Used By |
|
||||
|----------|----------|--------|---------|
|
||||
| **Invite Email** | `mail_service.go:SendInvite()` line 595-620 | Inline Go `html/template` | `UserHandler.InviteUser` |
|
||||
| **Test Email** | `settings_handler.go:SendTestEmail()` line 648-660 | Inline HTML string | Admin SMTP test |
|
||||
|
||||
**Invite template features:**
|
||||
- Gradient header with app name
|
||||
- "Accept Invitation" CTA button with invite URL
|
||||
- 48-hour expiration notice
|
||||
- Fallback plain-text URL link
|
||||
- Uses `{{.AppName}}` and `{{.InviteURL}}` variables
|
||||
|
||||
**Test template features:**
|
||||
- Simple confirmation message
|
||||
- Inline CSS styles
|
||||
- No template variables (hardcoded content)
|
||||
|
||||
### 5.2 No Shared Template System
|
||||
|
||||
Email templates are entirely separate from the notification template system (`NotificationTemplate` model). Notification templates are JSON-based for HTTP webhook payloads; email templates are HTML strings. There is no shared abstraction.
|
||||
|
||||
### 5.3 Missing Templates
|
||||
|
||||
For a full email notification feature, these templates would be needed:
|
||||
- **Security alert email** — WAF blocks, ACL denies, rate limit hits
|
||||
- **SSL certificate email** — renewal, expiry warnings, failures
|
||||
- **Uptime alert email** — host up/down transitions
|
||||
- **System event email** — config changes, backup completions
|
||||
- **Password reset email** (if user management expands)
|
||||
|
||||
---
|
||||
|
||||
## 6. Obsolete Code Inventory
|
||||
|
||||
### 6.1 Dead / Effectively Dead Code
|
||||
|
||||
| Item | Location | Status | Action |
|
||||
|------|----------|--------|--------|
|
||||
| `legacySendFunc` variable | `notification_service.go` | Returns `ErrLegacyFallbackDisabled` always | Remove |
|
||||
| `legacyFallbackInvocationError()` | `notification_service.go:61` | Only called by dead legacy path | Remove |
|
||||
| `ErrLegacyFallbackDisabled` error | `notification_service.go` | Guard for retired path | Remove |
|
||||
| `ShouldUseLegacyFallback()` | `router.go` | Always returns `false` | Remove |
|
||||
| `EngineLegacy` constant | `engine.go` | Unused | Remove |
|
||||
| `feature.notifications.legacy.fallback_enabled` flag | `feature_flags_handler.go:36` | Permanently retired | Remove from `defaultFlags` |
|
||||
| `retiredLegacyFallbackEnvAliases` | `feature_flags_handler.go` | Env aliases for retired flag | Remove |
|
||||
| Legacy test helpers | `notification_service_test.go` | Tests overriding `legacySendFunc` | Refactor/remove |
|
||||
| `security_notifications_test.go.archived` | `handlers/` | Archived test file | Delete |
|
||||
|
||||
### 6.2 Root-Level Artifacts (Should Be Gitignored)
|
||||
|
||||
| File | Purpose | Action |
|
||||
|------|---------|--------|
|
||||
| `FIREFOX_E2E_FIXES_SUMMARY.md` | One-time fix summary | Move to `docs/implementation/` or gitignore |
|
||||
| `verify-security-state-for-ui-tests` | Empty file (0 bytes) | Delete or gitignore |
|
||||
| `categories.txt` | Unknown (28 bytes) | Investigate, likely deletable |
|
||||
| `codeql-results-*.sarif` | Already gitignored pattern but files exist | Delete tracked files |
|
||||
| `grype-results.json`, `grype-results.sarif` | Scan artifacts | Already gitignored, delete tracked |
|
||||
| `sbom-generated.json`, `sbom.cyclonedx.json` | SBOM artifacts | Already gitignored, delete tracked |
|
||||
| `trivy-*.json` | Scan reports | Already gitignored pattern but tracked |
|
||||
| `vuln-results.json` | Vulnerability scan | Gitignore and delete |
|
||||
| `backend/test-output.txt` | Test run output | Gitignore |
|
||||
|
||||
### 6.3 Codecov / Dockerignore Gaps
|
||||
|
||||
**`codecov.yml`:** No SMTP/email/notification-specific exclusions or flags needed currently. Coverage threshold is 87%.
|
||||
|
||||
**`.dockerignore`:** Includes `*.sarif`, `sbom*.json`, `CODEQL_EMAIL_INJECTION_REMEDIATION_COMPLETE.md`. No SMTP-specific additions needed. The existing patterns are sufficient.
|
||||
|
||||
**`.gitignore`:** Missing patterns for:
|
||||
- `FIREFOX_E2E_FIXES_SUMMARY.md`
|
||||
- `verify-security-state-for-ui-tests`
|
||||
- `categories.txt`
|
||||
- `backend/test-output.txt`
|
||||
- `backend/*.out` (partially covered but `backend_full.out` may leak through)
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation Plan
|
||||
|
||||
### Phase 1: Cleanup — Dead Code & Artifacts (Low Risk)
|
||||
|
||||
**Goal:** Remove Shoutrrr residue, delete obsolete artifacts, fix gitignore gaps.
|
||||
|
||||
| Task | File(s) | Complexity |
|
||||
|------|---------|------------|
|
||||
| 1.1 Remove `legacySendFunc`, `ErrLegacyFallbackDisabled`, `legacyFallbackInvocationError()` | `notification_service.go` | S |
|
||||
| 1.2 Remove `ShouldUseLegacyFallback()` | `router.go` | S |
|
||||
| 1.3 Remove `EngineLegacy` constant | `engine.go` | S |
|
||||
| 1.4 Remove `feature.notifications.legacy.fallback_enabled` from `defaultFlags` and `defaultFlagValues` | `feature_flags_handler.go` | S |
|
||||
| 1.5 Remove `retiredLegacyFallbackEnvAliases` | `feature_flags_handler.go` | S |
|
||||
| 1.6 Refactor legacy test helpers in `notification_service_test.go` and `notification_service_json_test.go` | Test files | M |
|
||||
| 1.7 Delete `security_notifications_test.go.archived` | Handlers dir | S |
|
||||
| 1.8 Add missing `.gitignore` patterns and delete tracked artifacts from root | `.gitignore`, root files | S |
|
||||
| 1.9 Move `FIREFOX_E2E_FIXES_SUMMARY.md` to `docs/implementation/` | Root → docs/ | S |
|
||||
|
||||
### Phase 2: Email Feature Flag (Medium Risk)
|
||||
|
||||
**Goal:** Register email as a feature-flagged notification service.
|
||||
|
||||
| Task | File(s) | Complexity |
|
||||
|------|---------|------------|
|
||||
| 2.1 Add `FlagEmailServiceEnabled` constant | `notifications/feature_flags.go` | S |
|
||||
| 2.2 Add `feature.notifications.service.email.enabled` to `defaultFlags` + `defaultFlagValues` (default: `false`) | `feature_flags_handler.go` | S |
|
||||
| 2.3 Add `"email"` case to `isSupportedNotificationProviderType()` | `notification_service.go` | S |
|
||||
| 2.4 Add `"email"` case to `isDispatchEnabled()` with flag check | `notification_service.go` | S |
|
||||
| 2.5 Update frontend `FeatureFlags` interface and test fixtures | `tests/fixtures/settings.ts` | S |
|
||||
| 2.6 (Optional) Surface notification flags in `SystemSettings.tsx` `featureToggles` array | `SystemSettings.tsx` | M |
|
||||
| 2.7 Unit tests for new flag constant, dispatch enable check, provider type check | Test files | M |
|
||||
|
||||
### Phase 3: Email Notification Provider Integration (High Risk)
|
||||
|
||||
**Goal:** Wire `MailService` as a notification dispatch target alongside HTTP providers.
|
||||
|
||||
| Task | File(s) | Complexity |
|
||||
|------|---------|------------|
|
||||
| 3.1 Add `MailService` dependency to `NotificationService` | `notification_service.go` | M |
|
||||
| 3.2 Implement email dispatch branch in `SendExternal()` | `notification_service.go` | L |
|
||||
| 3.3 Define email notification template rendering (subject + HTML body from event context) | New or extend `mail_service.go` | L |
|
||||
| 3.4 Add email provider type to frontend `notifications.ts` API client | `frontend/src/api/notifications.ts` | S |
|
||||
| 3.5 Update notification provider UI to support email configuration (recipients, template selection) | Frontend components | L |
|
||||
| 3.6 Update `NotificationConfig.EmailRecipients` usage in `SecurityNotificationService` dispatch | `security_notification_service.go` | M |
|
||||
| 3.7 Integration tests for email dispatch path | Test files | L |
|
||||
| 3.8 E2E tests for email notification provider CRUD | `tests/settings/` | L |
|
||||
|
||||
### Phase 4: Email Templates (Medium Risk)
|
||||
|
||||
**Goal:** Create HTML email templates for all notification event types.
|
||||
|
||||
| Task | File(s) | Complexity |
|
||||
|------|---------|------------|
|
||||
| 4.1 Create reusable base email template with Charon branding | `backend/internal/services/` or `templates/` | M |
|
||||
| 4.2 Implement security alert email template | Template file | M |
|
||||
| 4.3 Implement SSL certificate event email template | Template file | M |
|
||||
| 4.4 Implement uptime event email template | Template file | M |
|
||||
| 4.5 Unit tests for all templates (variable rendering, sanitization) | Test files | M |
|
||||
|
||||
### Phase 5: Documentation & E2E Validation
|
||||
|
||||
| Task | File(s) | Complexity |
|
||||
|------|---------|------------|
|
||||
| 5.1 Update `docs/features/notifications.md` to include Email provider | `docs/features/notifications.md` | S |
|
||||
| 5.2 Add Email row to supported services table | `docs/features/notifications.md` | S |
|
||||
| 5.3 E2E tests for feature flag toggle affecting email dispatch | `tests/` | M |
|
||||
| 5.4 Full regression E2E run across all notification types | Playwright suites | M |
|
||||
|
||||
---
|
||||
|
||||
## 8. Test Plan
|
||||
|
||||
### 8.1 Backend Unit Tests
|
||||
|
||||
| Area | Test Cases | Priority |
|
||||
|------|-----------|----------|
|
||||
| `FlagEmailServiceEnabled` constant | Verify string value matches convention | P0 |
|
||||
| `isSupportedNotificationProviderType("email")` | Returns `true` | P0 |
|
||||
| `isDispatchEnabled("email")` | Returns flag-gated value | P0 |
|
||||
| `SendExternal` with email provider | Dispatches to MailService.SendEmail | P0 |
|
||||
| `SendExternal` with email flag disabled | Skips email provider | P0 |
|
||||
| Email template rendering | All variables rendered, XSS-safe | P1 |
|
||||
| `normalizeEmailRecipients` (if resurrected) | Valid/invalid email lists | P1 |
|
||||
| Legacy code removal | Verify `legacySendFunc` references removed | P0 |
|
||||
|
||||
### 8.2 Frontend Unit Tests
|
||||
|
||||
| Area | Test Cases | Priority |
|
||||
|------|-----------|----------|
|
||||
| Feature flag toggle for email | Renders in SystemSettings when flag exists | P1 |
|
||||
| Notification provider types | Includes "email" in supported types | P1 |
|
||||
| Email provider form | Renders recipient fields, validates emails | P2 |
|
||||
|
||||
### 8.3 E2E Tests (Playwright)
|
||||
|
||||
| Test | File | Priority |
|
||||
|------|------|----------|
|
||||
| SMTP settings CRUD (existing) | `smtp-settings.spec.ts` | P0 (already covered) |
|
||||
| Email feature flag toggle | New spec or extend `system-settings.spec.ts` | P1 |
|
||||
| Email notification provider CRUD | New spec in `tests/settings/` | P1 |
|
||||
| Email notification test send | Extend `notifications.spec.ts` | P2 |
|
||||
| Feature flag affects email dispatch | Integration-level E2E | P2 |
|
||||
|
||||
### 8.4 Existing Test Coverage Gaps
|
||||
|
||||
| Gap | Impact | Priority |
|
||||
|-----|--------|----------|
|
||||
| SMTP test email E2E doesn't test actual send (mocked) | Low — backend unit tests cover MailService | P2 |
|
||||
| No E2E test for invite email flow | Medium — relies on SMTP + public URL | P1 |
|
||||
| Notification E2E tests are Discord-only | High — gotify/webhook have no E2E | P1 |
|
||||
| Feature flag toggles not E2E-tested for notification flags | Medium — backend flags work, UI doesn't show them | P1 |
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation Strategy
|
||||
|
||||
### Decision: **Single PR, Staged Commits**
|
||||
|
||||
The full email notification feature lands in one PR on `feature/beta-release`. Work is organized into discrete commit stages that can be reviewed independently on the branch, cherry-picked if needed, and clearly bisected if a regression is introduced.
|
||||
|
||||
**Why single PR:**
|
||||
- The feature is self-contained and the flag defaults to `false` — no behavioral change lands until the flag is toggled
|
||||
- All four stages are tightly coupled (dead code removal creates the clean baseline the flag registration depends on, which the integration depends on, which templates depend on)
|
||||
- A single PR keeps the review diff contiguous and avoids merge-order coordination overhead
|
||||
|
||||
---
|
||||
|
||||
### Commit Stage 1: `chore: remove Shoutrrr residue and dead notification legacy code`
|
||||
|
||||
**Goal:** Clean codebase baseline. No behavior changes.
|
||||
|
||||
**Files:**
|
||||
- `backend/internal/services/notification_service.go` — remove `legacySendFunc`, `ErrLegacyFallbackDisabled`, `legacyFallbackInvocationError()`
|
||||
- `backend/internal/notifications/router.go` — remove `ShouldUseLegacyFallback()`, update `ShouldUseNotify()` to remove `EngineLegacy` reference
|
||||
- `backend/internal/notifications/engine.go` — remove `EngineLegacy` constant
|
||||
- `backend/internal/api/handlers/feature_flags_handler.go` — remove `feature.notifications.legacy.fallback_enabled` from `defaultFlags` + `defaultFlagValues`, remove `retiredLegacyFallbackEnvAliases`
|
||||
- `backend/internal/services/notification_service_test.go` — refactor/remove legacy `legacySendFunc` override test helpers
|
||||
- `backend/internal/services/notification_service_json_test.go` — remove legacy path overrides
|
||||
- `backend/internal/api/handlers/security_notifications_test.go.archived` — delete file
|
||||
- `backend/internal/models/notification_config.go` — remove orphaned `EmailRecipients` field
|
||||
- `.gitignore` — add missing patterns (root artifacts, `FIREFOX_E2E_FIXES_SUMMARY.md`, `verify-security-state-for-ui-tests`, `categories.txt`, `backend/test-output.txt`, `backend/*.out`)
|
||||
- Root-level tracked scan artifacts — delete (`codeql-results-*.sarif`, `grype-results.*`, `sbom-generated.json`, `sbom.cyclonedx.json`, `trivy-*.json`, `vuln-results.json`, `backend/test-output.txt`, `verify-security-state-for-ui-tests`, `categories.txt`)
|
||||
- `FIREFOX_E2E_FIXES_SUMMARY.md` → `docs/implementation/FIREFOX_E2E_FIXES_SUMMARY.md`
|
||||
- `ARCHITECTURE.instructions.md` tech stack table — update "Notifications" row from `Shoutrrr` to `Notify`
|
||||
|
||||
**Validation gate:** `go test ./...` green, no compilation errors, `npx playwright test` regression-clean.
|
||||
|
||||
---
|
||||
|
||||
### Commit Stage 2: `feat: register email as feature-flagged notification service`
|
||||
|
||||
**Goal:** Email flag exists in the system; defaults to `false`; no dispatch wiring yet.
|
||||
|
||||
**Files:**
|
||||
- `backend/internal/notifications/feature_flags.go` — add `FlagEmailServiceEnabled = "feature.notifications.service.email.enabled"`
|
||||
- `backend/internal/api/handlers/feature_flags_handler.go` — add flag to `defaultFlags` + `defaultFlagValues` (default: `false`)
|
||||
- `backend/internal/services/notification_service.go` — add `"email"` to `isSupportedNotificationProviderType()` and `isDispatchEnabled()` (gated by `FlagEmailServiceEnabled`)
|
||||
- `backend/internal/services/notification_service_test.go` — add unit tests for new flag constant, `isSupportedNotificationProviderType("email")` returns `true`, `isDispatchEnabled("email")` respects flag
|
||||
- `tests/fixtures/settings.ts` — expand `FeatureFlags` interface to include `feature.notifications.service.email.enabled`
|
||||
|
||||
**Validation gate:** `go test ./...` green, feature flag API returns new key with `false` default, E2E fixture types compile.
|
||||
|
||||
---
|
||||
|
||||
### Commit Stage 3: `feat: wire MailService into notification dispatch pipeline`
|
||||
|
||||
**Goal:** Email works as a first-class notification provider. `SendEmail()` accepts a context. Dispatch is async, guarded, and timeout-bound.
|
||||
|
||||
**Files:**
|
||||
- `backend/internal/services/mail_service.go`
|
||||
- Refactor `SendEmail(to, subject, htmlBody string)` → `SendEmail(ctx context.Context, to []string, subject, htmlBody string) error` (multi-recipient, context-aware)
|
||||
- Add recipient validation function: RFC 5322 format, max 20 recipients, `\r\n` header injection rejection
|
||||
- `backend/internal/services/notification_service.go`
|
||||
- Add `mailService MailServiceInterface` dependency (interface for testability)
|
||||
- Add `dispatchEmail(ctx context.Context, provider NotificationProvider, eventType, title, message string)` — resolves recipients from provider config, calls `s.mailService.IsConfigured()` guard (warn + return if not), renders HTML, calls `SendEmail()` with 30s timeout context
|
||||
- In `SendExternal()`: before `sendJSONPayload`, add email branch: `if providerType == "email" { go s.dispatchEmail(...); continue }`
|
||||
- `supportsJSONTemplates()` — do NOT include `"email"` (email uses its own rendering path)
|
||||
- `backend/internal/models/notification_provider.go` — document how email provider config is stored: `Type = "email"`, `URL` field repurposed as comma-separated recipient list (no schema migration needed; backward-compatible)
|
||||
- `frontend/src/api/notifications.ts` — add `"email"` to supported provider type union
|
||||
- Frontend notification provider form component — add recipient field (comma-separated emails) when type is `"email"`, hide URL/token fields
|
||||
- Backend unit tests:
|
||||
- `dispatchEmail` with SMTP unconfigured → logs warning, no error
|
||||
- `dispatchEmail` with empty recipient list → graceful skip
|
||||
- `dispatchEmail` with invalid recipient `"not-an-email"` → validation rejects
|
||||
- `dispatchEmail` concurrent calls → goroutine-safe
|
||||
- `SendExternal` with email provider → calls `dispatchEmail`, not `sendJSONPayload`
|
||||
- `SendExternal` with email flag disabled → skips email provider
|
||||
- Template rendering with XSS-payload event data → sanitized output
|
||||
- E2E tests:
|
||||
- Email provider CRUD (create, edit, delete)
|
||||
- Email provider with email flag disabled — provider exists but dispatch is skipped
|
||||
- Test send triggers correct API call with proper payload
|
||||
|
||||
**Validation gate:** `go test ./...` green, GORM security scan clean, email provider can be created and dispatches correctly when flag is enabled and SMTP is configured.
|
||||
|
||||
---
|
||||
|
||||
### Commit Stage 4: `feat: add HTML email templates for notification event types`
|
||||
|
||||
**Goal:** Each notification event type produces a properly branded, XSS-safe HTML email.
|
||||
|
||||
**Files:**
|
||||
- `backend/internal/services/templates/` (new directory using `embed.FS`)
|
||||
- `email_base.html` — Charon-branded base layout (gradient header, footer, responsive)
|
||||
- `email_security_alert.html` — WAF blocks, ACL denies, rate limit hits
|
||||
- `email_ssl_event.html` — certificate renewal, expiry warnings, failures
|
||||
- `email_uptime_event.html` — host up/down transitions
|
||||
- `email_system_event.html` — config changes, backup completions
|
||||
- `backend/internal/services/mail_service.go` — add `RenderNotificationEmail(templateName string, data interface{}) (string, error)` using `embed.FS` + `html/template`
|
||||
- Backend unit tests:
|
||||
- Each template renders correctly with valid data
|
||||
- Templates with malicious input (XSS) produce sanitized output (html/template auto-escapes)
|
||||
- Missing template name returns descriptive error
|
||||
- `docs/features/notifications.md` — add Email provider section with configuration guide
|
||||
- E2E tests:
|
||||
- Email notification content verification (intercept API, verify rendered body structure)
|
||||
- Feature flag toggle E2E — enabling/disabling `feature.notifications.service.email.enabled` flag from UI
|
||||
|
||||
**Validation gate:** All templates render safely, docs updated, full Playwright suite regression-clean.
|
||||
|
||||
---
|
||||
|
||||
### Cross-Stage Notes
|
||||
|
||||
- **Rate limiting for email notifications** is deferred to a follow-up issue. For v1, fire-and-forget with the 30s timeout context is acceptable. A digest/cooldown mechanism will be tracked as a tech debt item.
|
||||
- **Per-user recipient preferences** are out of scope. Recipients are configured globally per provider record.
|
||||
- **Email queue/retry** is deferred. Transient SMTP failures log a warning and drop the notification (same behavior as webhook failures).
|
||||
|
||||
---
|
||||
|
||||
## 10. Risk Assessment & Recommendations
|
||||
|
||||
### 10.1 Risks
|
||||
|
||||
| Risk | Severity | Likelihood | Mitigation |
|
||||
|------|----------|------------|------------|
|
||||
| Email dispatch in hot path blocks event processing | High | Medium | Dispatch async (goroutine), same pattern as `InviteUser` |
|
||||
| SMTP credentials exposed in logs | Critical | Low | Already masked (`MaskPassword`), ensure new paths follow same pattern |
|
||||
| Email injection via notification event data | High | Low | MailService already has CWE-93 protection; ensure template variables are sanitized |
|
||||
| Feature flag toggle enables email before SMTP is configured | Medium | Medium | Check `MailService.IsConfigured()` before dispatch, log warning if not |
|
||||
| Legacy Shoutrrr test removal breaks coverage | Low | Low | Replace with notify-path equivalents before removing |
|
||||
|
||||
### 10.2 `.gitignore` Recommendations
|
||||
|
||||
Add the following patterns:
|
||||
```gitignore
|
||||
# One-off summary files
|
||||
FIREFOX_E2E_FIXES_SUMMARY.md
|
||||
|
||||
# Empty marker files
|
||||
verify-security-state-for-ui-tests
|
||||
|
||||
# Misc artifacts
|
||||
categories.txt
|
||||
|
||||
# Backend test output
|
||||
backend/test-output.txt
|
||||
backend/*.out
|
||||
```
|
||||
|
||||
### 10.3 `codecov.yml` Recommendations
|
||||
|
||||
No changes needed. The 87% target is appropriate. Email-related code is in `services/` which is already covered.
|
||||
|
||||
### 10.4 `.dockerignore` Recommendations
|
||||
|
||||
No changes needed. Existing patterns cover scan artifacts, SARIF files, and SBOM outputs.
|
||||
|
||||
### 10.5 `Dockerfile` Recommendations
|
||||
|
||||
No SMTP-specific changes needed. The Dockerfile does not need to expose SMTP ports — Charon is an SMTP **client**, not server. The existing single-container architecture with Go backend + React frontend remains appropriate.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A: File Reference Map
|
||||
|
||||
| File | Lines Read | Key Findings |
|
||||
|------|-----------|--------------|
|
||||
| `backend/go.mod` | Full | No shoutrrr, no nikoksr/notify |
|
||||
| `backend/internal/services/mail_service.go` | 1-650 | Complete MailService with security hardening |
|
||||
| `backend/internal/services/notification_service.go` | 1-600 | Custom notify system, no email type |
|
||||
| `backend/internal/services/security_notification_service.go` | 1-120 | Webhook-only dispatch, EmailRecipients unused |
|
||||
| `backend/internal/services/enhanced_security_notification_service.go` | 1-180 | Provider aggregation, no email support |
|
||||
| `backend/internal/notifications/feature_flags.go` | 1-10 | 5 constants, no email |
|
||||
| `backend/internal/notifications/engine.go` | Grep | EngineLegacy dead |
|
||||
| `backend/internal/notifications/router.go` | Grep | Legacy fallback disabled |
|
||||
| `backend/internal/notifications/http_wrapper.go` | 1-100 | SSRF-protected HTTP client |
|
||||
| `backend/internal/api/handlers/feature_flags_handler.go` | 1-200 | 9 flags, no email |
|
||||
| `backend/internal/api/handlers/settings_handler.go` | 520-700 | SMTP CRUD + test email handlers |
|
||||
| `backend/internal/api/handlers/user_handler.go` | 466-650 | Invite email flow |
|
||||
| `backend/internal/models/notification_config.go` | 1-50 | EmailRecipients field present but unused |
|
||||
| `backend/internal/models/notification_provider.go` | Grep | No email type |
|
||||
| `frontend/src/api/smtp.ts` | Full | Complete SMTP API client |
|
||||
| `frontend/src/api/featureFlags.ts` | Full | Generic get/update, no email flag |
|
||||
| `frontend/src/api/notifications.ts` | 1-100 | Discord/gotify/webhook only |
|
||||
| `frontend/src/pages/SMTPSettings.tsx` | Full | Complete SMTP settings UI |
|
||||
| `frontend/src/pages/SystemSettings.tsx` | 185-320 | Only 3 feature toggles shown |
|
||||
| `tests/fixtures/settings.ts` | Full | FeatureFlags missing notification flags |
|
||||
| `tests/settings/smtp-settings.spec.ts` | 1-200 | Comprehensive SMTP E2E tests |
|
||||
| `tests/settings/notifications.spec.ts` | 1-50 | Discord-focused E2E tests |
|
||||
| `docs/features/notifications.md` | Full | No email provider documented |
|
||||
| `codecov.yml` | Full | 87% target, no SMTP exclusions |
|
||||
| `.gitignore` | Grep | Missing patterns for root artifacts |
|
||||
| `.dockerignore` | Grep | Adequate coverage |
|
||||
|
||||
## Appendix B: Research Questions Answered
|
||||
|
||||
**Q1: Is Shoutrrr fully removed?**
|
||||
Yes. No dependency in `go.mod`, no runtime code path. Only residual test names and one archived test file remain.
|
||||
|
||||
**Q2: Does an email feature flag exist?**
|
||||
No. Must be created as `feature.notifications.service.email.enabled`.
|
||||
|
||||
**Q3: Is MailService integrated with NotificationService?**
|
||||
No. They are completely independent. MailService is only consumed by UserHandler (invites) and SettingsHandler (test email).
|
||||
|
||||
**Q4: What is the state of `NotificationConfig.EmailRecipients`?**
|
||||
Orphaned. Defined in model, set to empty string in defaults, never consumed by any dispatch logic. The handler function `normalizeEmailRecipients()` is archived.
|
||||
|
||||
**Q5: What frontend gaps exist for email notifications?**
|
||||
- `FeatureFlags` interface missing notification flags
|
||||
- `SystemSettings.tsx` only shows 3 of 9 feature toggles
|
||||
- `notifications.ts` API client has no email provider type
|
||||
- No email-specific notification provider UI components
|
||||
@@ -1,6 +1,6 @@
|
||||
import client from './client';
|
||||
|
||||
export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = ['discord', 'gotify', 'webhook'] as const;
|
||||
export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = ['discord', 'gotify', 'webhook', 'email'] as const;
|
||||
export type SupportedNotificationProviderType = (typeof SUPPORTED_NOTIFICATION_PROVIDER_TYPES)[number];
|
||||
const DEFAULT_PROVIDER_TYPE: SupportedNotificationProviderType = 'discord';
|
||||
|
||||
|
||||
@@ -596,7 +596,10 @@
|
||||
"webhookUrl": "Webhook URL (Optional)",
|
||||
"webhookUrlHelp": "POST requests will be sent to this URL when security events occur.",
|
||||
"emailRecipients": "Email Recipients (Optional)",
|
||||
"emailRecipientsHelp": "Comma-separated email addresses."
|
||||
"emailRecipientsHelp": "Comma-separated email addresses.",
|
||||
"recipients": "Recipients",
|
||||
"recipientsHelp": "Comma-separated email addresses (max 20)",
|
||||
"emailSmtpNotice": "Email notifications are sent via the configured SMTP server. Ensure SMTP is configured in Settings \u2192 SMTP."
|
||||
},
|
||||
"users": {
|
||||
"title": "User Management",
|
||||
|
||||
@@ -139,6 +139,7 @@ const ProviderForm: FC<{
|
||||
|
||||
const type = normalizeProviderType(watch('type'));
|
||||
const isGotify = type === 'gotify';
|
||||
const isEmail = type === 'email';
|
||||
useEffect(() => {
|
||||
if (type !== 'gotify') {
|
||||
setValue('gotify_token', '', { shouldDirty: false, shouldTouch: false });
|
||||
@@ -194,30 +195,46 @@ const ProviderForm: FC<{
|
||||
<option value="discord">Discord</option>
|
||||
<option value="gotify">Gotify</option>
|
||||
<option value="webhook">{t('notificationProviders.genericWebhook')}</option>
|
||||
<option value="email">Email</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="provider-url" className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('notificationProviders.urlWebhook')} <span aria-hidden="true">*</span></label>
|
||||
<label htmlFor="provider-url" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{isEmail ? t('notificationProviders.recipients') : <>{t('notificationProviders.urlWebhook')} <span aria-hidden="true">*</span></>}
|
||||
</label>
|
||||
{isEmail && (
|
||||
<p id="email-recipients-help" className="text-xs text-gray-500 mt-0.5">
|
||||
{t('notificationProviders.recipientsHelp')}
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
id="provider-url"
|
||||
{...register('url', {
|
||||
required: t('notificationProviders.urlRequired') as string,
|
||||
validate: validateUrl,
|
||||
required: isEmail ? false : (t('notificationProviders.urlRequired') as string),
|
||||
validate: isEmail ? undefined : validateUrl,
|
||||
})}
|
||||
data-testid="provider-url"
|
||||
placeholder={type === 'discord' ? 'https://discord.com/api/webhooks/...' : type === 'gotify' ? 'https://gotify.example.com/message' : 'https://example.com/webhook'}
|
||||
placeholder={isEmail ? 'user@example.com, admin@example.com' : type === 'discord' ? 'https://discord.com/api/webhooks/...' : type === 'gotify' ? 'https://gotify.example.com/message' : 'https://example.com/webhook'}
|
||||
className={`mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm ${errors.url ? 'border-red-500' : ''}`}
|
||||
aria-invalid={errors.url ? 'true' : 'false'}
|
||||
aria-describedby={errors.url ? 'provider-url-error' : undefined}
|
||||
aria-describedby={isEmail ? 'email-recipients-help' : errors.url ? 'provider-url-error' : undefined}
|
||||
/>
|
||||
{errors.url && (
|
||||
{!isEmail && errors.url && (
|
||||
<span id="provider-url-error" data-testid="provider-url-error" className="text-red-500 text-xs">
|
||||
{errors.url.message as string}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEmail && (
|
||||
<div role="note" className="rounded-md bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-3">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{t('notificationProviders.emailSmtpNotice')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGotify && (
|
||||
<div>
|
||||
<label htmlFor="provider-gotify-token" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock('react-i18next', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../api/notifications', () => ({
|
||||
SUPPORTED_NOTIFICATION_PROVIDER_TYPES: ['discord', 'gotify', 'webhook'],
|
||||
SUPPORTED_NOTIFICATION_PROVIDER_TYPES: ['discord', 'gotify', 'webhook', 'email'],
|
||||
getProviders: vi.fn(),
|
||||
createProvider: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
@@ -146,8 +146,8 @@ describe('Notifications', () => {
|
||||
const typeSelect = screen.getByTestId('provider-type') as HTMLSelectElement
|
||||
const options = Array.from(typeSelect.options)
|
||||
|
||||
expect(options).toHaveLength(3)
|
||||
expect(options.map((option) => option.value)).toEqual(['discord', 'gotify', 'webhook'])
|
||||
expect(options).toHaveLength(4)
|
||||
expect(options.map((option) => option.value)).toEqual(['discord', 'gotify', 'webhook', 'email'])
|
||||
expect(typeSelect.disabled).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -294,8 +294,8 @@ test.describe('Notification Providers', () => {
|
||||
|
||||
await test.step('Verify provider type select contains supported options', async () => {
|
||||
const providerTypeSelect = page.getByTestId('provider-type');
|
||||
await expect(providerTypeSelect.locator('option')).toHaveCount(3);
|
||||
await expect(providerTypeSelect.locator('option')).toHaveText(['Discord', 'Gotify', 'Generic Webhook']);
|
||||
await expect(providerTypeSelect.locator('option')).toHaveCount(4);
|
||||
await expect(providerTypeSelect.locator('option')).toHaveText(['Discord', 'Gotify', 'Generic Webhook', 'Email']);
|
||||
await expect(providerTypeSelect).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user