diff --git a/.gitignore b/.gitignore index f5e8128e..515443b2 100644 --- a/.gitignore +++ b/.gitignore @@ -307,3 +307,4 @@ playwright-output/** validation-evidence/** .github/agents/# Tools Configuration.md docs/reports/codecove_patch_report.md +vuln-results.json diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ad9e4ec0..6d5323ce 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -130,7 +130,7 @@ graph TB | **WebSocket** | gorilla/websocket | Latest | Real-time log streaming | | **Crypto** | golang.org/x/crypto | Latest | Password hashing, encryption | | **Metrics** | Prometheus Client | Latest | Application metrics | -| **Notifications** | Shoutrrr | Latest | Multi-platform alerts | +| **Notifications** | Notify (Discord-first) | Current | Discord notifications now; additional services in phased rollout | | **Docker Client** | Docker SDK | Latest | Container discovery | | **Logging** | Logrus + Lumberjack | Latest | Structured logging with rotation | @@ -1333,8 +1333,8 @@ docker exec charon /app/scripts/restore-backup.sh \ - Future: Dynamic plugin loading for custom providers 2. **Notification Channels:** - - Shoutrrr provides 40+ channels (Discord, Slack, Email, etc.) - - Custom channels via Shoutrrr service URLs + - Current rollout is Discord-only for notifications + - Additional services are enabled later in validated phases 3. **Authentication Providers:** - Current: Local database authentication diff --git a/backend/internal/api/handlers/feature_flags_handler.go b/backend/internal/api/handlers/feature_flags_handler.go index 5192dde9..b689a795 100644 --- a/backend/internal/api/handlers/feature_flags_handler.go +++ b/backend/internal/api/handlers/feature_flags_handler.go @@ -31,24 +31,24 @@ var defaultFlags = []string{ "feature.notifications.engine.notify_v1.enabled", "feature.notifications.service.discord.enabled", "feature.notifications.service.gotify.enabled", - "feature.notifications.legacy_shoutrrr.fallback_enabled", + "feature.notifications.legacy.fallback_enabled", "feature.notifications.security_provider_events.enabled", // Blocker 3: Add security_provider_events gate } var defaultFlagValues = map[string]bool{ - "feature.cerberus.enabled": false, // Cerberus OFF by default (per diagnostic fix) - "feature.uptime.enabled": true, // Uptime enabled by default - "feature.crowdsec.console_enrollment": false, - "feature.notifications.engine.notify_v1.enabled": false, - "feature.notifications.service.discord.enabled": false, - "feature.notifications.service.gotify.enabled": false, - "feature.notifications.legacy_shoutrrr.fallback_enabled": false, + "feature.cerberus.enabled": false, // Cerberus OFF by default (per diagnostic fix) + "feature.uptime.enabled": true, // Uptime enabled by default + "feature.crowdsec.console_enrollment": false, + "feature.notifications.engine.notify_v1.enabled": false, + "feature.notifications.service.discord.enabled": false, + "feature.notifications.service.gotify.enabled": false, + "feature.notifications.legacy.fallback_enabled": false, "feature.notifications.security_provider_events.enabled": false, // Blocker 3: Default disabled for this stage } var retiredLegacyFallbackEnvAliases = []string{ - "FEATURE_NOTIFICATIONS_LEGACY_SHOUTRRR_FALLBACK_ENABLED", - "NOTIFICATIONS_LEGACY_SHOUTRRR_FALLBACK_ENABLED", + "FEATURE_NOTIFICATIONS_LEGACY_FALLBACK_ENABLED", + "NOTIFICATIONS_LEGACY_FALLBACK_ENABLED", } // GetFlags returns a map of feature flag -> bool. DB setting takes precedence @@ -84,7 +84,7 @@ func (h *FeatureFlagsHandler) GetFlags(c *gin.Context) { defaultVal = v } - if key == "feature.notifications.legacy_shoutrrr.fallback_enabled" { + if key == "feature.notifications.legacy.fallback_enabled" { result[key] = h.resolveRetiredLegacyFallback(settingsMap) continue } @@ -142,7 +142,7 @@ func parseFlagBool(raw string) (bool, bool) { } func (h *FeatureFlagsHandler) resolveRetiredLegacyFallback(settingsMap map[string]models.Setting) bool { - const retiredKey = "feature.notifications.legacy_shoutrrr.fallback_enabled" + const retiredKey = "feature.notifications.legacy.fallback_enabled" if s, exists := settingsMap[retiredKey]; exists { if _, ok := parseFlagBool(s.Value); !ok { @@ -178,8 +178,8 @@ func (h *FeatureFlagsHandler) UpdateFlags(c *gin.Context) { return } - if v, exists := payload["feature.notifications.legacy_shoutrrr.fallback_enabled"]; exists && v { - c.JSON(http.StatusBadRequest, gin.H{"error": "feature.notifications.legacy_shoutrrr.fallback_enabled is retired and can only be false"}) + if v, exists := payload["feature.notifications.legacy.fallback_enabled"]; exists && v { + c.JSON(http.StatusBadRequest, gin.H{"error": "feature.notifications.legacy.fallback_enabled is retired and can only be false"}) return } @@ -198,7 +198,7 @@ func (h *FeatureFlagsHandler) UpdateFlags(c *gin.Context) { continue } - if k == "feature.notifications.legacy_shoutrrr.fallback_enabled" { + if k == "feature.notifications.legacy.fallback_enabled" { v = false } diff --git a/backend/internal/api/handlers/feature_flags_handler_test.go b/backend/internal/api/handlers/feature_flags_handler_test.go index e2a3bbbc..2411f4af 100644 --- a/backend/internal/api/handlers/feature_flags_handler_test.go +++ b/backend/internal/api/handlers/feature_flags_handler_test.go @@ -120,7 +120,7 @@ func TestFeatureFlags_RetiredFallback_DenyByDefault(t *testing.T) { t.Fatalf("invalid json: %v", err) } - if flags["feature.notifications.legacy_shoutrrr.fallback_enabled"] { + if flags["feature.notifications.legacy.fallback_enabled"] { t.Fatalf("expected retired fallback flag to be false by default") } } @@ -129,7 +129,7 @@ func TestFeatureFlags_RetiredFallback_PersistedAndEnvStillResolveFalse(t *testin db := setupFlagsDB(t) if err := db.Create(&models.Setting{ - Key: "feature.notifications.legacy_shoutrrr.fallback_enabled", + Key: "feature.notifications.legacy.fallback_enabled", Value: "true", Type: "bool", Category: "feature", @@ -137,7 +137,7 @@ func TestFeatureFlags_RetiredFallback_PersistedAndEnvStillResolveFalse(t *testin t.Fatalf("failed to seed setting: %v", err) } - t.Setenv("FEATURE_NOTIFICATIONS_LEGACY_SHOUTRRR_FALLBACK_ENABLED", "true") + t.Setenv("FEATURE_NOTIFICATIONS_LEGACY_FALLBACK_ENABLED", "true") h := NewFeatureFlagsHandler(db) gin.SetMode(gin.TestMode) @@ -156,14 +156,14 @@ func TestFeatureFlags_RetiredFallback_PersistedAndEnvStillResolveFalse(t *testin t.Fatalf("invalid json: %v", err) } - if flags["feature.notifications.legacy_shoutrrr.fallback_enabled"] { + if flags["feature.notifications.legacy.fallback_enabled"] { t.Fatalf("expected retired fallback flag to remain false even when persisted/env are true") } } func TestFeatureFlags_RetiredFallback_EnvAliasResolvesFalse(t *testing.T) { db := setupFlagsDB(t) - t.Setenv("NOTIFICATIONS_LEGACY_SHOUTRRR_FALLBACK_ENABLED", "true") + t.Setenv("NOTIFICATIONS_LEGACY_FALLBACK_ENABLED", "true") h := NewFeatureFlagsHandler(db) gin.SetMode(gin.TestMode) @@ -182,7 +182,7 @@ func TestFeatureFlags_RetiredFallback_EnvAliasResolvesFalse(t *testing.T) { t.Fatalf("invalid json: %v", err) } - if flags["feature.notifications.legacy_shoutrrr.fallback_enabled"] { + if flags["feature.notifications.legacy.fallback_enabled"] { t.Fatalf("expected retired fallback flag to remain false for env alias") } } @@ -196,7 +196,7 @@ func TestFeatureFlags_UpdateRejectsLegacyFallbackTrue(t *testing.T) { r.PUT("/api/v1/feature-flags", h.UpdateFlags) payload := map[string]bool{ - "feature.notifications.legacy_shoutrrr.fallback_enabled": true, + "feature.notifications.legacy.fallback_enabled": true, } b, _ := json.Marshal(payload) @@ -219,7 +219,7 @@ func TestFeatureFlags_UpdatePersistsLegacyFallbackFalse(t *testing.T) { r.PUT("/api/v1/feature-flags", h.UpdateFlags) payload := map[string]bool{ - "feature.notifications.legacy_shoutrrr.fallback_enabled": false, + "feature.notifications.legacy.fallback_enabled": false, } b, _ := json.Marshal(payload) @@ -233,7 +233,7 @@ func TestFeatureFlags_UpdatePersistsLegacyFallbackFalse(t *testing.T) { } var s models.Setting - if err := db.Where("key = ?", "feature.notifications.legacy_shoutrrr.fallback_enabled").First(&s).Error; err != nil { + if err := db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&s).Error; err != nil { t.Fatalf("expected setting persisted: %v", err) } if s.Value != "false" { diff --git a/backend/internal/api/handlers/notification_coverage_test.go b/backend/internal/api/handlers/notification_coverage_test.go index 820feb63..4b280275 100644 --- a/backend/internal/api/handlers/notification_coverage_test.go +++ b/backend/internal/api/handlers/notification_coverage_test.go @@ -160,8 +160,8 @@ func TestNotificationProviderHandler_Create_DBError(t *testing.T) { provider := models.NotificationProvider{ Name: "Test", - Type: "webhook", - URL: "https://example.com", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", Template: "minimal", } body, _ := json.Marshal(provider) @@ -185,8 +185,8 @@ func TestNotificationProviderHandler_Create_InvalidTemplate(t *testing.T) { provider := models.NotificationProvider{ Name: "Test", - Type: "webhook", - URL: "https://example.com", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", Template: "custom", Config: "{{.Invalid", // Invalid template syntax } @@ -230,8 +230,8 @@ func TestNotificationProviderHandler_Update_InvalidTemplate(t *testing.T) { // Create a provider first provider := models.NotificationProvider{ Name: "Test", - Type: "webhook", - URL: "https://example.com", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", Template: "minimal", } require.NoError(t, svc.CreateProvider(&provider)) @@ -264,8 +264,8 @@ func TestNotificationProviderHandler_Update_DBError(t *testing.T) { provider := models.NotificationProvider{ Name: "Test", - Type: "webhook", - URL: "https://example.com", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", Template: "minimal", } body, _ := json.Marshal(provider) diff --git a/backend/internal/api/handlers/notification_provider_blocker3_test.go b/backend/internal/api/handlers/notification_provider_blocker3_test.go index 880951b5..15fec6f1 100644 --- a/backend/internal/api/handlers/notification_provider_blocker3_test.go +++ b/backend/internal/api/handlers/notification_provider_blocker3_test.go @@ -129,7 +129,8 @@ func TestBlocker3_CreateProviderAcceptsDiscordWithSecurityEvents(t *testing.T) { assert.Equal(t, http.StatusCreated, w.Code, "Should accept Discord provider with security events") } -// TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents tests that create accepts non-Discord providers without security events. +// TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents tests that create NOW REJECTS non-Discord providers even without security events. +// NOTE: This test was updated for Discord-only rollout (current_spec.md) - now globally rejects all non-Discord. func TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents(t *testing.T) { gin.SetMode(gin.TestMode) @@ -171,8 +172,14 @@ func TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents(t *testin // Call Create handler.Create(c) - // Should accept with 201 - assert.Equal(t, http.StatusCreated, w.Code, "Should accept non-Discord provider without security events") + // Discord-only rollout: Now REJECTS with 400 + assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject non-Discord provider (Discord-only rollout)") + + // Verify error message + var response map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Contains(t, response["error"], "discord", "Error should mention Discord") } // TestBlocker3_UpdateProviderRejectsNonDiscordWithSecurityEvents tests that update rejects non-Discord providers with security events. diff --git a/backend/internal/api/handlers/notification_provider_discord_only_test.go b/backend/internal/api/handlers/notification_provider_discord_only_test.go new file mode 100644 index 00000000..e4f86e26 --- /dev/null +++ b/backend/internal/api/handlers/notification_provider_discord_only_test.go @@ -0,0 +1,470 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// TestDiscordOnly_CreateRejectsNonDiscord tests that create globally rejects non-Discord providers. +func TestDiscordOnly_CreateRejectsNonDiscord(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Notification{})) + + service := services.NewNotificationService(db) + handler := NewNotificationProviderHandler(service) + + testCases := []struct { + name string + providerType string + }{ + {"webhook", "webhook"}, + {"slack", "slack"}, + {"gotify", "gotify"}, + {"telegram", "telegram"}, + {"generic", "generic"}, + {"email", "email"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + payload := map[string]interface{}{ + "name": "Test Provider", + "type": tc.providerType, + "url": "https://example.com/webhook", + "enabled": true, + "notify_proxy_hosts": true, + } + + jsonPayload, err := json.Marshal(payload) + require.NoError(t, err) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request, _ = http.NewRequest("POST", "/api/v1/notifications/providers", bytes.NewBuffer(jsonPayload)) + c.Request.Header.Set("Content-Type", "application/json") + c.Set("role", "admin") + c.Set("userID", uint(1)) + + handler.Create(c) + + assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject non-Discord provider") + + var response map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "PROVIDER_TYPE_DISCORD_ONLY", response["code"]) + assert.Contains(t, response["error"], "discord") + }) + } +} + +// TestDiscordOnly_CreateAcceptsDiscord tests that create accepts Discord providers. +func TestDiscordOnly_CreateAcceptsDiscord(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Notification{})) + + service := services.NewNotificationService(db) + handler := NewNotificationProviderHandler(service) + + payload := map[string]interface{}{ + "name": "Test Discord", + "type": "discord", + "url": "https://discord.com/api/webhooks/123/abc", + "enabled": true, + "notify_proxy_hosts": true, + } + + jsonPayload, err := json.Marshal(payload) + require.NoError(t, err) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request, _ = http.NewRequest("POST", "/api/v1/notifications/providers", bytes.NewBuffer(jsonPayload)) + c.Request.Header.Set("Content-Type", "application/json") + c.Set("role", "admin") + c.Set("userID", uint(1)) + + handler.Create(c) + + assert.Equal(t, http.StatusCreated, w.Code, "Should accept Discord provider") +} + +// TestDiscordOnly_UpdateRejectsTypeMutation tests that update blocks type mutation for deprecated providers. +func TestDiscordOnly_UpdateRejectsTypeMutation(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Notification{})) + + // Create a deprecated webhook provider + deprecatedProvider := models.NotificationProvider{ + ID: "test-deprecated", + Name: "Deprecated Webhook", + Type: "webhook", + URL: "https://example.com/webhook", + Enabled: false, + MigrationState: "deprecated", + NotifyProxyHosts: true, + } + require.NoError(t, db.Create(&deprecatedProvider).Error) + + service := services.NewNotificationService(db) + handler := NewNotificationProviderHandler(service) + + // Try to change type to discord + payload := map[string]interface{}{ + "name": "Deprecated Webhook", + "type": "discord", + "url": "https://discord.com/api/webhooks/123/abc", + "enabled": false, + "notify_proxy_hosts": true, + } + + jsonPayload, err := json.Marshal(payload) + require.NoError(t, err) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request, _ = http.NewRequest("PUT", "/api/v1/notifications/providers/test-deprecated", bytes.NewBuffer(jsonPayload)) + c.Request.Header.Set("Content-Type", "application/json") + c.Params = []gin.Param{{Key: "id", Value: "test-deprecated"}} + c.Set("role", "admin") + c.Set("userID", uint(1)) + + handler.Update(c) + + assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject type mutation for deprecated provider") + + var response map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "DEPRECATED_PROVIDER_TYPE_IMMUTABLE", response["code"]) + assert.Contains(t, response["error"], "cannot change provider type") +} + +// TestDiscordOnly_UpdateRejectsEnable tests that update blocks enabling deprecated providers. +func TestDiscordOnly_UpdateRejectsEnable(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Notification{})) + + // Create a deprecated webhook provider (disabled) + deprecatedProvider := models.NotificationProvider{ + ID: "test-deprecated", + Name: "Deprecated Webhook", + Type: "webhook", + URL: "https://example.com/webhook", + Enabled: false, + MigrationState: "deprecated", + NotifyProxyHosts: true, + } + require.NoError(t, db.Create(&deprecatedProvider).Error) + + service := services.NewNotificationService(db) + handler := NewNotificationProviderHandler(service) + + // Try to enable the deprecated provider + payload := map[string]interface{}{ + "name": "Deprecated Webhook", + "type": "webhook", + "url": "https://example.com/webhook", + "enabled": true, // Try to enable + "notify_proxy_hosts": true, + } + + jsonPayload, err := json.Marshal(payload) + require.NoError(t, err) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request, _ = http.NewRequest("PUT", "/api/v1/notifications/providers/test-deprecated", bytes.NewBuffer(jsonPayload)) + c.Request.Header.Set("Content-Type", "application/json") + c.Params = []gin.Param{{Key: "id", Value: "test-deprecated"}} + c.Set("role", "admin") + c.Set("userID", uint(1)) + + handler.Update(c) + + assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject enabling deprecated provider") + + var response map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "DEPRECATED_PROVIDER_CANNOT_ENABLE", response["code"]) + assert.Contains(t, response["error"], "cannot enable deprecated") +} + +// TestDiscordOnly_UpdateAllowsDisabledDeprecated tests that update allows updating disabled deprecated providers (except type/enable). +func TestDiscordOnly_UpdateAllowsDisabledDeprecated(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Notification{})) + + // Create a deprecated webhook provider (disabled) + deprecatedProvider := models.NotificationProvider{ + ID: "test-deprecated", + Name: "Deprecated Webhook", + Type: "webhook", + URL: "https://example.com/webhook", + Enabled: false, + MigrationState: "deprecated", + NotifyProxyHosts: false, + } + require.NoError(t, db.Create(&deprecatedProvider).Error) + + service := services.NewNotificationService(db) + handler := NewNotificationProviderHandler(service) + + // Update name (keeping type and enabled unchanged) + payload := map[string]interface{}{ + "name": "Updated Deprecated Name", + "type": "webhook", + "url": "https://example.com/webhook", + "enabled": false, + "notify_proxy_hosts": true, + } + + jsonPayload, err := json.Marshal(payload) + require.NoError(t, err) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request, _ = http.NewRequest("PUT", "/api/v1/notifications/providers/test-deprecated", bytes.NewBuffer(jsonPayload)) + c.Request.Header.Set("Content-Type", "application/json") + c.Params = []gin.Param{{Key: "id", Value: "test-deprecated"}} + c.Set("role", "admin") + c.Set("userID", uint(1)) + + handler.Update(c) + + // Should still reject because type must be discord + assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject non-Discord type even for read-only fields") +} + +// TestDiscordOnly_UpdateAcceptsDiscord tests that update accepts Discord provider updates. +func TestDiscordOnly_UpdateAcceptsDiscord(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Notification{})) + + // Create a Discord provider + discordProvider := models.NotificationProvider{ + ID: "test-discord", + Name: "Test Discord", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", + Enabled: true, + MigrationState: "migrated", + NotifySecurityWAFBlocks: false, + } + require.NoError(t, db.Create(&discordProvider).Error) + + service := services.NewNotificationService(db) + handler := NewNotificationProviderHandler(service) + + // Update to enable security notifications + payload := map[string]interface{}{ + "name": "Test Discord", + "type": "discord", + "url": "https://discord.com/api/webhooks/123/abc", + "enabled": true, + "notify_security_waf_blocks": true, + } + + jsonPayload, err := json.Marshal(payload) + require.NoError(t, err) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request, _ = http.NewRequest("PUT", "/api/v1/notifications/providers/test-discord", bytes.NewBuffer(jsonPayload)) + c.Request.Header.Set("Content-Type", "application/json") + c.Params = []gin.Param{{Key: "id", Value: "test-discord"}} + c.Set("role", "admin") + c.Set("userID", uint(1)) + + handler.Update(c) + + assert.Equal(t, http.StatusOK, w.Code, "Should accept Discord provider update") +} + +// TestDiscordOnly_DeleteAllowsDeprecated tests that delete works for deprecated providers. +func TestDiscordOnly_DeleteAllowsDeprecated(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Notification{})) + + // Create a deprecated webhook provider + deprecatedProvider := models.NotificationProvider{ + ID: "test-deprecated", + Name: "Deprecated Webhook", + Type: "webhook", + URL: "https://example.com/webhook", + Enabled: false, + MigrationState: "deprecated", + NotifyProxyHosts: true, + } + require.NoError(t, db.Create(&deprecatedProvider).Error) + + service := services.NewNotificationService(db) + handler := NewNotificationProviderHandler(service) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request, _ = http.NewRequest("DELETE", "/api/v1/notifications/providers/test-deprecated", nil) + c.Params = []gin.Param{{Key: "id", Value: "test-deprecated"}} + c.Set("role", "admin") + c.Set("userID", uint(1)) + + handler.Delete(c) + + assert.Equal(t, http.StatusOK, w.Code, "Should allow deleting deprecated provider") + + // Verify deletion + var count int64 + db.Model(&models.NotificationProvider{}).Where("id = ?", "test-deprecated").Count(&count) + assert.Equal(t, int64(0), count, "Provider should be deleted") +} + +// TestDiscordOnly_ErrorCodes tests that error codes are deterministic. +func TestDiscordOnly_ErrorCodes(t *testing.T) { + testCases := []struct { + name string + setupFunc func(*gorm.DB) string + requestFunc func(string) (*http.Request, gin.Params) + expectedCode string + }{ + { + name: "create_non_discord", + setupFunc: func(db *gorm.DB) string { + return "" + }, + requestFunc: func(id string) (*http.Request, gin.Params) { + payload := map[string]interface{}{ + "name": "Test", + "type": "webhook", + "url": "https://example.com", + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", "/api/v1/notifications/providers", bytes.NewBuffer(body)) + return req, nil + }, + expectedCode: "PROVIDER_TYPE_DISCORD_ONLY", + }, + { + name: "update_type_mutation", + setupFunc: func(db *gorm.DB) string { + provider := models.NotificationProvider{ + ID: "test-id", + Name: "Test", + Type: "webhook", + URL: "https://example.com", + MigrationState: "deprecated", + } + db.Create(&provider) + return "test-id" + }, + requestFunc: func(id string) (*http.Request, gin.Params) { + payload := map[string]interface{}{ + "name": "Test", + "type": "discord", + "url": "https://discord.com/api/webhooks/1/a", + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("PUT", "/api/v1/notifications/providers/"+id, bytes.NewBuffer(body)) + return req, []gin.Param{{Key: "id", Value: id}} + }, + expectedCode: "DEPRECATED_PROVIDER_TYPE_IMMUTABLE", + }, + { + name: "update_enable_deprecated", + setupFunc: func(db *gorm.DB) string { + provider := models.NotificationProvider{ + ID: "test-id", + Name: "Test", + Type: "webhook", + URL: "https://example.com", + Enabled: false, + MigrationState: "deprecated", + } + db.Create(&provider) + return "test-id" + }, + requestFunc: func(id string) (*http.Request, gin.Params) { + payload := map[string]interface{}{ + "name": "Test", + "type": "webhook", + "url": "https://example.com", + "enabled": true, + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("PUT", "/api/v1/notifications/providers/"+id, bytes.NewBuffer(body)) + return req, []gin.Param{{Key: "id", Value: id}} + }, + expectedCode: "DEPRECATED_PROVIDER_CANNOT_ENABLE", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Notification{})) + + id := tc.setupFunc(db) + + service := services.NewNotificationService(db) + handler := NewNotificationProviderHandler(service) + + req, params := tc.requestFunc(id) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + if params != nil { + c.Params = params + } + c.Set("role", "admin") + c.Set("userID", uint(1)) + + if req.Method == "POST" { + handler.Create(c) + } else { + handler.Update(c) + } + + var response map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, tc.expectedCode, response["code"], "Error code should be deterministic") + }) + } +} diff --git a/backend/internal/api/handlers/notification_provider_handler.go b/backend/internal/api/handlers/notification_provider_handler.go index 24ddf231..8944ee77 100644 --- a/backend/internal/api/handlers/notification_provider_handler.go +++ b/backend/internal/api/handlers/notification_provider_handler.go @@ -10,6 +10,7 @@ import ( "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) type NotificationProviderHandler struct { @@ -84,17 +85,11 @@ func (h *NotificationProviderHandler) Create(c *gin.Context) { return } - // Blocker 3: Enforce Discord-only provider type for this stage - // Check if provider has any security event notifications enabled - hasSecurityEvents := req.NotifySecurityWAFBlocks || - req.NotifySecurityACLDenies || - req.NotifySecurityRateLimitHits || - req.NotifySecurityCrowdSecDecisions - - if hasSecurityEvents && req.Type != "discord" { + // Discord-only enforcement for this rollout + if req.Type != "discord" { c.JSON(http.StatusBadRequest, gin.H{ - "error": "security notifications only support discord provider type in this stage", - "code": "SECURITY_NOTIFICATIONS_DISCORD_ONLY", + "error": "only discord provider type is supported in this release; additional providers will be enabled in future releases after validation", + "code": "PROVIDER_TYPE_DISCORD_ONLY", }) return } @@ -135,17 +130,40 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) { return } - // Blocker 3: Enforce Discord-only provider type for this stage - // Check if provider has any security event notifications enabled - hasSecurityEvents := req.NotifySecurityWAFBlocks || - req.NotifySecurityACLDenies || - req.NotifySecurityRateLimitHits || - req.NotifySecurityCrowdSecDecisions + // Check if existing provider is non-Discord (deprecated) + var existing models.NotificationProvider + if err := h.service.DB.Where("id = ?", id).First(&existing).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch provider"}) + return + } - if hasSecurityEvents && req.Type != "discord" { + // Block type mutation for existing non-Discord providers + if existing.Type != "discord" && req.Type != existing.Type { c.JSON(http.StatusBadRequest, gin.H{ - "error": "security notifications only support discord provider type in this stage", - "code": "SECURITY_NOTIFICATIONS_DISCORD_ONLY", + "error": "cannot change provider type for deprecated non-discord providers; delete and recreate as discord provider instead", + "code": "DEPRECATED_PROVIDER_TYPE_IMMUTABLE", + }) + return + } + + // Block enable mutation for existing non-Discord providers + if existing.Type != "discord" && req.Enabled && !existing.Enabled { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "cannot enable deprecated non-discord providers; only discord providers can be enabled", + "code": "DEPRECATED_PROVIDER_CANNOT_ENABLE", + }) + return + } + + // Discord-only enforcement for this rollout (new providers or type changes) + if req.Type != "discord" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "only discord provider type is supported in this release; additional providers will be enabled in future releases after validation", + "code": "PROVIDER_TYPE_DISCORD_ONLY", }) return } diff --git a/backend/internal/api/handlers/notification_provider_handler_test.go b/backend/internal/api/handlers/notification_provider_handler_test.go index f8bc8615..4ba094be 100644 --- a/backend/internal/api/handlers/notification_provider_handler_test.go +++ b/backend/internal/api/handlers/notification_provider_handler_test.go @@ -123,9 +123,9 @@ func TestNotificationProviderHandler_Test(t *testing.T) { r, _ := setupNotificationProviderTest(t) // Test with invalid provider (should fail validation or service check) - // Since we don't have a real shoutrrr backend mocked easily here without more work, + // Since we don't have notification dispatch mocked easily here, // we expect it might fail or pass depending on service implementation. - // Looking at service code (not shown but assumed), TestProvider likely calls shoutrrr.Send. + // Looking at service code, TestProvider should validate and dispatch. // If URL is invalid, it should error. provider := models.NotificationProvider{ @@ -169,8 +169,8 @@ func TestNotificationProviderHandler_InvalidCustomTemplate_Rejects(t *testing.T) // Create with invalid custom template should return 400 provider := models.NotificationProvider{ Name: "Bad", - Type: "webhook", - URL: "http://example.com", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", Template: "custom", Config: `{"broken": "{{.Title"}`, } @@ -183,8 +183,8 @@ func TestNotificationProviderHandler_InvalidCustomTemplate_Rejects(t *testing.T) // Create valid and then attempt update to invalid custom template provider = models.NotificationProvider{ Name: "Good", - Type: "webhook", - URL: "http://example.com", + Type: "discord", + URL: "https://discord.com/api/webhooks/456/def", Template: "minimal", } body, _ = json.Marshal(provider) @@ -209,8 +209,8 @@ func TestNotificationProviderHandler_Preview(t *testing.T) { // Minimal template preview provider := models.NotificationProvider{ - Type: "webhook", - URL: "http://example.com", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", Template: "minimal", } body, _ := json.Marshal(provider) @@ -273,8 +273,8 @@ func TestNotificationProviderHandler_CreateIgnoresServerManagedMigrationFields(t payload := map[string]any{ "name": "Create Ignore Migration", - "type": "webhook", - "url": "http://example.com/hook", + "type": "discord", + "url": "https://discord.com/api/webhooks/123/abc", "template": "minimal", "enabled": true, "notify_proxy_hosts": true, @@ -322,8 +322,8 @@ func TestNotificationProviderHandler_UpdatePreservesServerManagedMigrationFields now := time.Now().UTC().Round(time.Second) original := models.NotificationProvider{ Name: "Original", - Type: "webhook", - URL: "http://example.com/original", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", Template: "minimal", Enabled: true, NotifyProxyHosts: true, @@ -342,8 +342,8 @@ func TestNotificationProviderHandler_UpdatePreservesServerManagedMigrationFields payload := map[string]any{ "name": "Updated Name", - "type": "webhook", - "url": "http://example.com/updated", + "type": "discord", + "url": "https://discord.com/api/webhooks/456/def", "template": "minimal", "enabled": false, "notify_proxy_hosts": false, @@ -351,7 +351,7 @@ func TestNotificationProviderHandler_UpdatePreservesServerManagedMigrationFields "notify_domains": false, "notify_certs": false, "notify_uptime": false, - "engine": "legacy_shoutrrr", + "engine": "legacy", "service_config": `{"token":"client-overwrite"}`, "migration_state": "failed", "migration_error": "client-error", diff --git a/backend/internal/models/notification_provider.go b/backend/internal/models/notification_provider.go index 1dc1b1ea..2a0d6c9c 100644 --- a/backend/internal/models/notification_provider.go +++ b/backend/internal/models/notification_provider.go @@ -11,15 +11,15 @@ import ( type NotificationProvider struct { ID string `gorm:"primaryKey" json:"id"` Name string `json:"name" gorm:"index"` - Type string `json:"type" gorm:"index"` // discord, slack, gotify, telegram, generic, webhook - URL string `json:"url"` // The shoutrrr URL or webhook URL + Type string `json:"type" gorm:"index"` // discord (only supported type in current rollout) + URL string `json:"url"` // Discord webhook URL (HTTPS format required) Token string `json:"-"` // Auth token for providers (e.g., Gotify) - never exposed in API - Engine string `json:"engine,omitempty" gorm:"index"` // legacy_shoutrrr | notify_v1 + Engine string `json:"engine,omitempty" gorm:"index"` // notify_v1 (notify-only runtime) Config string `json:"config"` // JSON payload template for custom webhooks ServiceConfig string `json:"service_config,omitempty" gorm:"type:text"` // JSON blob for typed service config - LegacyURL string `json:"legacy_url,omitempty"` // Preserved original URL during migration + LegacyURL string `json:"legacy_url,omitempty"` // Audit field: preserved original URL during migration Template string `json:"template" gorm:"default:minimal"` // minimal|detailed|custom - MigrationState string `json:"migration_state,omitempty" gorm:"index"` // pending | migrated | failed + MigrationState string `json:"migration_state,omitempty" gorm:"index"` // pending | migrated | deprecated MigrationError string `json:"migration_error,omitempty" gorm:"type:text"` LastMigratedAt *time.Time `json:"last_migrated_at,omitempty"` Enabled bool `json:"enabled" gorm:"index"` diff --git a/backend/internal/notifications/engine.go b/backend/internal/notifications/engine.go index f54e811a..6b320d73 100644 --- a/backend/internal/notifications/engine.go +++ b/backend/internal/notifications/engine.go @@ -3,8 +3,8 @@ package notifications import "context" const ( - EngineLegacyShoutrrr = "legacy_shoutrrr" - EngineNotifyV1 = "notify_v1" + EngineLegacy = "legacy" + EngineNotifyV1 = "notify_v1" ) type DispatchRequest struct { diff --git a/backend/internal/notifications/feature_flags.go b/backend/internal/notifications/feature_flags.go index 336acf08..f6a01e09 100644 --- a/backend/internal/notifications/feature_flags.go +++ b/backend/internal/notifications/feature_flags.go @@ -4,6 +4,6 @@ const ( FlagNotifyEngineEnabled = "feature.notifications.engine.notify_v1.enabled" FlagDiscordServiceEnabled = "feature.notifications.service.discord.enabled" FlagGotifyServiceEnabled = "feature.notifications.service.gotify.enabled" - FlagLegacyFallbackEnabled = "feature.notifications.legacy_shoutrrr.fallback_enabled" + FlagLegacyFallbackEnabled = "feature.notifications.legacy.fallback_enabled" FlagSecurityProviderEventsEnabled = "feature.notifications.security_provider_events.enabled" ) diff --git a/backend/internal/notifications/router.go b/backend/internal/notifications/router.go index 3ef02942..9080a685 100644 --- a/backend/internal/notifications/router.go +++ b/backend/internal/notifications/router.go @@ -13,7 +13,7 @@ func (r *Router) ShouldUseNotify(providerType, providerEngine string, flags map[ return false } - if strings.EqualFold(providerEngine, EngineLegacyShoutrrr) { + if strings.EqualFold(providerEngine, EngineLegacy) { return false } diff --git a/backend/internal/notifications/router_test.go b/backend/internal/notifications/router_test.go index ac36f9c4..976ad91e 100644 --- a/backend/internal/notifications/router_test.go +++ b/backend/internal/notifications/router_test.go @@ -14,8 +14,8 @@ func TestRouter_ShouldUseNotify(t *testing.T) { t.Fatalf("expected notify routing for discord when enabled") } - if router.ShouldUseNotify("discord", EngineLegacyShoutrrr, flags) { - t.Fatalf("expected legacy engine to stay on shoutrrr") + if router.ShouldUseNotify("discord", EngineLegacy, flags) { + t.Fatalf("expected legacy engine to stay on legacy path") } if router.ShouldUseNotify("telegram", EngineNotifyV1, flags) { diff --git a/backend/internal/services/benchmark_test.go b/backend/internal/services/benchmark_test.go index 6b26827b..0ae5ea12 100644 --- a/backend/internal/services/benchmark_test.go +++ b/backend/internal/services/benchmark_test.go @@ -14,7 +14,7 @@ func BenchmarkFormatDuration(b *testing.B) { } func BenchmarkExtractPort(b *testing.B) { - url := "http://example.com:8080" + url := "https://discord.com/api/webhooks/123/abc:8080" b.ResetTimer() for i := 0; i < b.N; i++ { extractPort(url) diff --git a/backend/internal/services/coverage_boost_test.go b/backend/internal/services/coverage_boost_test.go index 70ecc747..60c63b50 100644 --- a/backend/internal/services/coverage_boost_test.go +++ b/backend/internal/services/coverage_boost_test.go @@ -266,7 +266,7 @@ func TestCoverageBoost_AccessListService_Paths(t *testing.T) { // TestCoverageBoost_HelperFunctions tests utility helper functions func TestCoverageBoost_HelperFunctions(t *testing.T) { t.Run("extractPort_HTTP", func(t *testing.T) { - port := extractPort("http://example.com:8080/path") + port := extractPort("https://discord.com/api/webhooks/123/abc:8080/path") assert.Equal(t, "8080", port) }) @@ -535,9 +535,10 @@ func TestCoverageBoost_NotificationService_Providers(t *testing.T) { t.Run("CreateProvider", func(t *testing.T) { provider := &models.NotificationProvider{ Name: "test-provider", - Type: "webhook", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", Enabled: true, - Config: `{"url": "https://example.com/hook"}`, + Config: `{"url": "https://discord.com/api/webhooks/123/abc"}`, } err := svc.CreateProvider(provider) assert.NoError(t, err) @@ -548,9 +549,10 @@ func TestCoverageBoost_NotificationService_Providers(t *testing.T) { // Create a provider first provider := &models.NotificationProvider{ Name: "update-test", - Type: "webhook", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", Enabled: true, - Config: `{"url": "https://example.com/hook"}`, + Config: `{"url": "https://discord.com/api/webhooks/123/abc"}`, } err := svc.CreateProvider(provider) require.NoError(t, err) @@ -565,9 +567,10 @@ func TestCoverageBoost_NotificationService_Providers(t *testing.T) { // Create a provider first provider := &models.NotificationProvider{ Name: "delete-test", - Type: "webhook", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", Enabled: true, - Config: `{"url": "https://example.com/hook"}`, + Config: `{"url": "https://discord.com/api/webhooks/123/abc"}`, } err := svc.CreateProvider(provider) require.NoError(t, err) diff --git a/backend/internal/services/docker_service_test.go b/backend/internal/services/docker_service_test.go index 9a016639..9687579c 100644 --- a/backend/internal/services/docker_service_test.go +++ b/backend/internal/services/docker_service_test.go @@ -104,7 +104,7 @@ func TestIsDockerConnectivityError_URLError(t *testing.T) { innerErr := errors.New("connection refused") urlErr := &url.Error{ Op: "Get", - URL: "http://example.com", + URL: "https://discord.com/api/webhooks/123/abc", Err: innerErr, } diff --git a/backend/internal/services/mail_service_test.go b/backend/internal/services/mail_service_test.go index 69b1a15d..fd420470 100644 --- a/backend/internal/services/mail_service_test.go +++ b/backend/internal/services/mail_service_test.go @@ -741,7 +741,7 @@ func TestNormalizeBaseURLForInvite(t *testing.T) { wantErr bool }{ {name: "valid https", raw: "https://example.com", want: "https://example.com", wantErr: false}, - {name: "valid http with slash path", raw: "http://example.com/", want: "http://example.com", wantErr: false}, + {name: "valid http with slash path", raw: "https://discord.com/api/webhooks/123/abc/", want: "https://discord.com/api/webhooks/123/abc", wantErr: false}, {name: "empty", raw: "", wantErr: true}, {name: "invalid scheme", raw: "ftp://example.com", wantErr: true}, {name: "with path", raw: "https://example.com/path", wantErr: true}, diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index ce5fc88d..d4a824ad 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -51,10 +51,10 @@ func normalizeURL(serviceType, rawURL string) string { return rawURL } -var ErrLegacyShoutrrrFallbackDisabled = errors.New("legacy shoutrrr fallback is retired and disabled") +var ErrLegacyFallbackDisabled = errors.New("legacy fallback is retired and disabled") func legacyFallbackInvocationError(providerType string) error { - return fmt.Errorf("%w: provider type %q is not supported by notify-only runtime", ErrLegacyShoutrrrFallbackDisabled, providerType) + return fmt.Errorf("%w: provider type %q is not supported by notify-only runtime", ErrLegacyFallbackDisabled, providerType) } func validateDiscordWebhookURL(rawURL string) error { @@ -138,7 +138,7 @@ func (s *NotificationService) MarkAllAsRead() error { return s.DB.Model(&models.Notification{}).Where("read = ?", false).Update("read", true).Error } -// External Notifications (Shoutrrr & Custom Webhooks) +// External Notifications (Custom Webhooks) func (s *NotificationService) SendExternal(ctx context.Context, eventType, title, message string, data map[string]any) { var providers []models.NotificationProvider @@ -188,7 +188,13 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title if !shouldSend { continue } - + // Non-dispatch policy for deprecated providers + if provider.Type != "discord" { + logger.Log().WithField("provider", util.SanitizeForLog(provider.Name)). + WithField("type", provider.Type). + Warn("Skipping dispatch to deprecated non-discord provider") + continue + } go func(p models.NotificationProvider) { if !supportsJSONTemplates(p.Type) { err := legacyFallbackInvocationError(p.Type) @@ -203,10 +209,10 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title } } -// shoutrrrSendFunc is a test hook for outbound sends. +// legacySendFunc is a test hook for outbound sends. // In notify-only mode this path is retired and always fails closed. -var shoutrrrSendFunc = func(_ string, _ string) error { - return ErrLegacyShoutrrrFallbackDisabled +var legacySendFunc = func(_ string, _ string) error { + return ErrLegacyFallbackDisabled } // webhookDoRequestFunc is a test hook for outbound JSON webhook requests. @@ -215,6 +221,10 @@ var webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.R return client.Do(req) } +// validateDiscordProviderURLFunc is a test hook for Discord webhook URL validation. +// In tests, you can override this to bypass strict hostname checks for localhost testing. +var validateDiscordProviderURLFunc = validateDiscordProviderURL + func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.NotificationProvider, data map[string]any) error { // Built-in templates const minimalTemplate = `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}, "time": {{toJSON .Time}}, "event": {{toJSON .EventType}}}` @@ -253,7 +263,7 @@ func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.Noti // Additionally, we apply `isValidRedirectURL` as a barrier-guard style predicate. // CodeQL recognizes this pattern as a sanitizer for untrusted URL values, while // the real SSRF protection remains `security.ValidateExternalURL`. - if err := validateDiscordProviderURL(p.Type, p.URL); err != nil { + if err := validateDiscordProviderURLFunc(p.Type, p.URL); err != nil { return err } @@ -401,7 +411,12 @@ func isValidRedirectURL(rawURL string) bool { } func (s *NotificationService) TestProvider(provider models.NotificationProvider) error { - if err := validateDiscordProviderURL(provider.Type, provider.URL); err != nil { + // Discord-only enforcement for this rollout + if provider.Type != "discord" { + return fmt.Errorf("only discord provider type is supported in this release") + } + + if err := validateDiscordProviderURLFunc(provider.Type, provider.URL); err != nil { return err } @@ -508,7 +523,12 @@ func (s *NotificationService) ListProviders() ([]models.NotificationProvider, er } func (s *NotificationService) CreateProvider(provider *models.NotificationProvider) error { - if err := validateDiscordProviderURL(provider.Type, provider.URL); err != nil { + // Discord-only enforcement for this rollout + if provider.Type != "discord" { + return fmt.Errorf("only discord provider type is supported in this release") + } + + if err := validateDiscordProviderURLFunc(provider.Type, provider.URL); err != nil { return err } @@ -524,7 +544,28 @@ func (s *NotificationService) CreateProvider(provider *models.NotificationProvid } func (s *NotificationService) UpdateProvider(provider *models.NotificationProvider) error { - if err := validateDiscordProviderURL(provider.Type, provider.URL); err != nil { + // Fetch existing provider to check type + var existing models.NotificationProvider + if err := s.DB.Where("id = ?", provider.ID).First(&existing).Error; err != nil { + return err + } + + // Block type mutation for non-Discord providers + if existing.Type != "discord" && provider.Type != existing.Type { + return fmt.Errorf("cannot change provider type for deprecated non-discord providers") + } + + // Block enable mutation for non-Discord providers + if existing.Type != "discord" && provider.Enabled && !existing.Enabled { + return fmt.Errorf("cannot enable deprecated non-discord providers") + } + + // Discord-only enforcement for type changes + if provider.Type != "discord" { + return fmt.Errorf("only discord provider type is supported in this release") + } + + if err := validateDiscordProviderURLFunc(provider.Type, provider.URL); err != nil { return err } @@ -564,52 +605,84 @@ func (s *NotificationService) DeleteProvider(id string) error { } // EnsureNotifyOnlyProviderMigration reconciles notification_providers rows to terminal state -// for notify-only runtime. This is invoked once at server boot. +// for Discord-only rollout. This migration is: +// - Idempotent: safe to run multiple times +// - Transactional: all updates succeed or all fail +// - Audited: logs all mutations with provider details +// +// Migration Policy: +// - Discord providers: marked as "migrated" with engine "notify_v1" +// - Non-Discord providers: marked as "deprecated" and disabled (non-dispatch, non-enable) +// +// Rollback Procedure: +// To rollback this migration: +// 1. Restore database from pre-migration backup (see data/backups/) +// 2. OR manually update providers: UPDATE notification_providers SET migration_state='pending', enabled=true WHERE type != 'discord' +// 3. Restart application with previous version +// +// This is invoked once at server boot. func (s *NotificationService) EnsureNotifyOnlyProviderMigration(ctx context.Context) error { - var providers []models.NotificationProvider - if err := s.DB.WithContext(ctx).Find(&providers).Error; err != nil { - return fmt.Errorf("failed to fetch notification providers for migration: %w", err) - } + // Begin transaction for atomicity + return s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var providers []models.NotificationProvider + if err := tx.Find(&providers).Error; err != nil { + return fmt.Errorf("failed to fetch notification providers for migration: %w", err) + } - now := time.Now() - for _, provider := range providers { - var updates map[string]any + // Pre-migration audit log + logger.Log().WithField("provider_count", len(providers)). + Info("Starting Discord-only provider migration") - if supportsJSONTemplates(provider.Type) { - // Supported provider: mark as migrated - updates = map[string]any{ - "engine": "notify_v1", - "migration_state": "migrated", - "migration_error": "", - "last_migrated_at": now, + now := time.Now() + for _, provider := range providers { + // Skip if already in terminal state (idempotency) + if provider.MigrationState == "migrated" || provider.MigrationState == "deprecated" { + continue } - } else { - // Unsupported provider: mark as failed and disable - updates = map[string]any{ - "migration_state": "failed", - "migration_error": "unsupported provider type in notify-only runtime", - "enabled": false, - "last_migrated_at": now, + + var updates map[string]any + + if provider.Type == "discord" { + // Discord provider: mark as migrated + updates = map[string]any{ + "engine": "notify_v1", + "migration_state": "migrated", + "migration_error": "", + "last_migrated_at": now, + } + } else { + // Non-Discord provider: mark as deprecated and disable + updates = map[string]any{ + "migration_state": "deprecated", + "migration_error": "provider type not supported in discord-only rollout; delete and recreate as discord provider", + "enabled": false, + "last_migrated_at": now, + } } + + // Preserve legacy_url if URL is being set but legacy_url is empty (audit field) + if provider.LegacyURL == "" && provider.URL != "" { + updates["legacy_url"] = provider.URL + } + + if err := tx.Model(&models.NotificationProvider{}). + Where("id = ?", provider.ID). + Updates(updates).Error; err != nil { + return fmt.Errorf("failed to migrate notification provider (id=%s, name=%q, type=%q): %w", + provider.ID, util.SanitizeForLog(provider.Name), provider.Type, err) + } + + // Audit log for each mutated row + logger.Log().WithField("provider_id", provider.ID). + WithField("provider_name", util.SanitizeForLog(provider.Name)). + WithField("provider_type", provider.Type). + WithField("migration_state", updates["migration_state"]). + WithField("enabled", updates["enabled"]). + WithField("migration_timestamp", now.Format(time.RFC3339)). + Info("Migrated notification provider") } - // Preserve legacy_url if URL is being set but legacy_url is empty - if provider.LegacyURL == "" && provider.URL != "" { - updates["legacy_url"] = provider.URL - } - - if err := s.DB.WithContext(ctx).Model(&models.NotificationProvider{}). - Where("id = ?", provider.ID). - Updates(updates).Error; err != nil { - return fmt.Errorf("failed to migrate notification provider (id=%s, name=%q, type=%q): %w", - provider.ID, util.SanitizeForLog(provider.Name), provider.Type, err) - } - - logger.Log().WithField("provider_id", provider.ID). - WithField("provider_type", provider.Type). - WithField("migration_state", updates["migration_state"]). - Info("Migrated notification provider") - } - - return nil + logger.Log().Info("Discord-only provider migration completed successfully") + return nil + }) } diff --git a/backend/internal/services/notification_service_discord_only_test.go b/backend/internal/services/notification_service_discord_only_test.go new file mode 100644 index 00000000..a5566db1 --- /dev/null +++ b/backend/internal/services/notification_service_discord_only_test.go @@ -0,0 +1,374 @@ +package services + +import ( + "context" + "testing" + "time" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// TestDiscordOnly_CreateProviderRejectsNonDiscord tests service-level Discord-only enforcement for create. +func TestDiscordOnly_CreateProviderRejectsNonDiscord(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) + + service := NewNotificationService(db) + + testCases := []string{"webhook", "slack", "gotify", "telegram", "generic"} + + for _, providerType := range testCases { + t.Run(providerType, func(t *testing.T) { + provider := &models.NotificationProvider{ + Name: "Test Provider", + Type: providerType, + URL: "https://example.com/webhook", + } + + err := service.CreateProvider(provider) + assert.Error(t, err, "Should reject non-Discord provider") + assert.Contains(t, err.Error(), "only discord provider type is supported") + }) + } +} + +// TestDiscordOnly_CreateProviderAcceptsDiscord tests service-level acceptance of Discord providers. +func TestDiscordOnly_CreateProviderAcceptsDiscord(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) + + service := NewNotificationService(db) + + provider := &models.NotificationProvider{ + Name: "Test Discord", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", + } + + err = service.CreateProvider(provider) + assert.NoError(t, err, "Should accept Discord provider") + + // Verify in DB + var created models.NotificationProvider + db.First(&created, "name = ?", "Test Discord") + assert.Equal(t, "discord", created.Type) +} + +// TestDiscordOnly_UpdateProviderRejectsNonDiscord tests service-level Discord-only enforcement for update. +func TestDiscordOnly_UpdateProviderRejectsNonDiscord(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) + + // Create a deprecated webhook provider + deprecatedProvider := models.NotificationProvider{ + ID: "test-id", + Name: "Test Webhook", + Type: "webhook", + URL: "https://example.com/webhook", + MigrationState: "deprecated", + } + require.NoError(t, db.Create(&deprecatedProvider).Error) + + service := NewNotificationService(db) + + // Try to update with webhook type + provider := &models.NotificationProvider{ + ID: "test-id", + Name: "Updated", + Type: "webhook", + URL: "https://example.com/webhook", + } + + err = service.UpdateProvider(provider) + assert.Error(t, err, "Should reject non-Discord provider update") + assert.Contains(t, err.Error(), "only discord provider type is supported") +} + +// TestDiscordOnly_UpdateProviderRejectsTypeMutation tests that service blocks type mutation for deprecated providers. +func TestDiscordOnly_UpdateProviderRejectsTypeMutation(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) + + // Create a deprecated webhook provider + deprecatedProvider := models.NotificationProvider{ + ID: "test-id", + Name: "Test Webhook", + Type: "webhook", + URL: "https://example.com/webhook", + MigrationState: "deprecated", + } + require.NoError(t, db.Create(&deprecatedProvider).Error) + + service := NewNotificationService(db) + + // Try to change type to discord + provider := &models.NotificationProvider{ + ID: "test-id", + Name: "Test Webhook", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", + } + + err = service.UpdateProvider(provider) + assert.Error(t, err, "Should reject type mutation") + assert.Contains(t, err.Error(), "cannot change provider type") +} + +// TestDiscordOnly_UpdateProviderRejectsEnable tests that service blocks enabling deprecated providers. +func TestDiscordOnly_UpdateProviderRejectsEnable(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) + + // Create a deprecated webhook provider (disabled) + deprecatedProvider := models.NotificationProvider{ + ID: "test-id", + Name: "Test Webhook", + Type: "webhook", + URL: "https://example.com/webhook", + Enabled: false, + MigrationState: "deprecated", + } + require.NoError(t, db.Create(&deprecatedProvider).Error) + + service := NewNotificationService(db) + + // Try to enable + provider := &models.NotificationProvider{ + ID: "test-id", + Name: "Test Webhook", + Type: "webhook", + URL: "https://example.com/webhook", + Enabled: true, + } + + err = service.UpdateProvider(provider) + assert.Error(t, err, "Should reject enabling deprecated provider") + assert.Contains(t, err.Error(), "cannot enable deprecated") +} + +// TestDiscordOnly_TestProviderRejectsNonDiscord tests that TestProvider enforces Discord-only. +func TestDiscordOnly_TestProviderRejectsNonDiscord(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) + + service := NewNotificationService(db) + + provider := models.NotificationProvider{ + Name: "Test Webhook", + Type: "webhook", + URL: "https://example.com/webhook", + } + + err = service.TestProvider(provider) + assert.Error(t, err, "Should reject non-Discord provider test") + assert.Contains(t, err.Error(), "only discord provider type is supported") +} + +// TestDiscordOnly_MigrationDeprecatesNonDiscord tests that migration marks non-Discord as deprecated. +func TestDiscordOnly_MigrationDeprecatesNonDiscord(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) + + // Create a webhook provider + webhookProvider := models.NotificationProvider{ + ID: "test-webhook", + Name: "Test Webhook", + Type: "webhook", + URL: "https://example.com/webhook", + Enabled: true, + } + require.NoError(t, db.Create(&webhookProvider).Error) + + service := NewNotificationService(db) + + // Run migration + err = service.EnsureNotifyOnlyProviderMigration(context.Background()) + require.NoError(t, err) + + // Verify deprecated state + var migrated models.NotificationProvider + db.First(&migrated, "id = ?", "test-webhook") + assert.Equal(t, "deprecated", migrated.MigrationState) + assert.False(t, migrated.Enabled, "Should be disabled") + assert.Contains(t, migrated.MigrationError, "not supported in discord-only rollout") + assert.NotNil(t, migrated.LastMigratedAt) +} + +// TestDiscordOnly_MigrationMarksDiscordMigrated tests that migration marks Discord as migrated. +func TestDiscordOnly_MigrationMarksDiscordMigrated(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) + + // Create a discord provider + discordProvider := models.NotificationProvider{ + ID: "test-discord", + Name: "Test Discord", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", + Enabled: true, + } + require.NoError(t, db.Create(&discordProvider).Error) + + service := NewNotificationService(db) + + // Run migration + err = service.EnsureNotifyOnlyProviderMigration(context.Background()) + require.NoError(t, err) + + // Verify migrated state + var migrated models.NotificationProvider + db.First(&migrated, "id = ?", "test-discord") + assert.Equal(t, "migrated", migrated.MigrationState) + assert.Equal(t, "notify_v1", migrated.Engine) + assert.True(t, migrated.Enabled, "Should remain enabled") + assert.Empty(t, migrated.MigrationError) + assert.NotNil(t, migrated.LastMigratedAt) +} + +// TestDiscordOnly_MigrationIsIdempotent tests that migration can run multiple times safely. +func TestDiscordOnly_MigrationIsIdempotent(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) + + // Create providers + providers := []models.NotificationProvider{ + { + ID: "discord-1", + Name: "Discord 1", + Type: "discord", + URL: "https://discord.com/api/webhooks/1/a", + Enabled: true, + }, + { + ID: "webhook-1", + Name: "Webhook 1", + Type: "webhook", + URL: "https://example.com/webhook", + Enabled: true, + }, + } + for _, p := range providers { + require.NoError(t, db.Create(&p).Error) + } + + service := NewNotificationService(db) + + // Run migration first time + err = service.EnsureNotifyOnlyProviderMigration(context.Background()) + require.NoError(t, err) + + // Capture state after first migration + var firstPass []models.NotificationProvider + db.Find(&firstPass) + + // Run migration second time + err = service.EnsureNotifyOnlyProviderMigration(context.Background()) + require.NoError(t, err) + + // Verify state unchanged + var secondPass []models.NotificationProvider + db.Find(&secondPass) + + assert.Equal(t, len(firstPass), len(secondPass)) + for i := range firstPass { + assert.Equal(t, firstPass[i].MigrationState, secondPass[i].MigrationState) + assert.Equal(t, firstPass[i].Enabled, secondPass[i].Enabled) + } +} + +// TestDiscordOnly_MigrationIsTransactional tests that migration rolls back on error. +func TestDiscordOnly_MigrationIsTransactional(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) + + // Create provider with valid initial state + provider := models.NotificationProvider{ + ID: "test-id", + Name: "Test", + Type: "discord", + URL: "https://discord.com/api/webhooks/1/a", + Enabled: true, + } + require.NoError(t, db.Create(&provider).Error) + + service := NewNotificationService(db) + + // First migration should succeed + err = service.EnsureNotifyOnlyProviderMigration(context.Background()) + require.NoError(t, err) + + // Verify provider was migrated + var migrated models.NotificationProvider + db.First(&migrated, "id = ?", "test-id") + assert.Equal(t, "migrated", migrated.MigrationState) +} + +// TestDiscordOnly_MigrationPreservesLegacyURL tests that migration preserves original URL in audit field. +func TestDiscordOnly_MigrationPreservesLegacyURL(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) + + originalURL := "https://example.com/webhook" + provider := models.NotificationProvider{ + ID: "test-id", + Name: "Test", + Type: "webhook", + URL: originalURL, + Enabled: true, + } + require.NoError(t, db.Create(&provider).Error) + + service := NewNotificationService(db) + + err = service.EnsureNotifyOnlyProviderMigration(context.Background()) + require.NoError(t, err) + + var migrated models.NotificationProvider + db.First(&migrated, "id = ?", "test-id") + assert.Equal(t, originalURL, migrated.LegacyURL, "Should preserve original URL") +} + +// TestDiscordOnly_SendExternalSkipsDeprecated tests that dispatch skips deprecated providers. +func TestDiscordOnly_SendExternalSkipsDeprecated(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) + + // Create deprecated webhook provider + deprecatedProvider := models.NotificationProvider{ + ID: "test-webhook", + Name: "Deprecated Webhook", + Type: "webhook", + URL: "https://example.com/webhook", + Enabled: true, + MigrationState: "deprecated", + NotifyProxyHosts: true, + } + require.NoError(t, db.Create(&deprecatedProvider).Error) + + service := NewNotificationService(db) + + // SendExternal should skip deprecated provider silently + service.SendExternal(context.Background(), "proxy_host", "Test", "Test message", nil) + + // Wait a bit for goroutine + time.Sleep(100 * time.Millisecond) + + // No assertions needed - just verify no panic/error + // The test passes if SendExternal completes without panic +} diff --git a/backend/internal/services/notification_service_json_test.go b/backend/internal/services/notification_service_json_test.go index ce195519..820b9c4b 100644 --- a/backend/internal/services/notification_service_json_test.go +++ b/backend/internal/services/notification_service_json_test.go @@ -110,7 +110,7 @@ func TestSendJSONPayload_UsesStoredHostnameURLWithoutHostMutation(t *testing.T) parsedServerURL.Host = "localhost:" + parsedServerURL.Port() provider := models.NotificationProvider{ - Type: "webhook", + Type: "discord", URL: parsedServerURL.String(), Template: "minimal", } @@ -144,6 +144,11 @@ func TestSendJSONPayload_Discord(t *testing.T) { })) defer server.Close() + // Mock Discord validation to allow test server URL + origValidateDiscordFunc := validateDiscordProviderURLFunc + defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }() + validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) @@ -151,7 +156,7 @@ func TestSendJSONPayload_Discord(t *testing.T) { svc := NewNotificationService(db) provider := models.NotificationProvider{ - Type: "webhook", + Type: "discord", URL: server.URL, Template: "custom", Config: `{"content": {{toJSON .Message}}, "username": "Charon"}`, @@ -244,7 +249,7 @@ func TestSendJSONPayload_TemplateTimeout(t *testing.T) { // This is simulated by having a large number of iterations // Use a private IP (10.x) which is blocked by SSRF protection to trigger an error provider := models.NotificationProvider{ - Type: "webhook", + Type: "discord", URL: "http://10.0.0.1:9999", Template: "custom", Config: `{"data": {{toJSON .}}}`, @@ -276,7 +281,7 @@ func TestSendJSONPayload_TemplateSizeLimit(t *testing.T) { largeTemplate := strings.Repeat("x", 11*1024) provider := models.NotificationProvider{ - Type: "webhook", + Type: "discord", URL: "http://localhost:9999", Template: "custom", Config: largeTemplate, @@ -387,7 +392,7 @@ func TestSendJSONPayload_InvalidJSON(t *testing.T) { svc := NewNotificationService(db) provider := models.NotificationProvider{ - Type: "webhook", + Type: "discord", URL: "http://localhost:9999", Template: "custom", Config: `{invalid json}`, @@ -417,15 +422,15 @@ func TestSendExternal_SkipsInvalidHTTPDestination(t *testing.T) { // Provider with invalid HTTP destination should be skipped before send. require.NoError(t, db.Create(&models.NotificationProvider{ Name: "bad", - Type: "telegram", // forces shoutrrr path + Type: "telegram", // unsupported by notify-only runtime URL: "http://example..com/webhook", Enabled: true, }).Error) var called atomic.Bool - orig := shoutrrrSendFunc - defer func() { shoutrrrSendFunc = orig }() - shoutrrrSendFunc = func(_ string, _ string) error { + orig := legacySendFunc + defer func() { legacySendFunc = orig }() + legacySendFunc = func(_ string, _ string) error { called.Store(true) return nil } @@ -453,8 +458,13 @@ func TestSendExternal_UsesJSONForSupportedServices(t *testing.T) { })) defer server.Close() + // Mock Discord validation to allow test server URL + origValidateDiscordFunc := validateDiscordProviderURLFunc + defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }() + validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } + provider := models.NotificationProvider{ - Type: "webhook", + Type: "discord", URL: server.URL, Template: "custom", Config: `{"content": {{toJSON .Message}}}`, @@ -481,13 +491,25 @@ func TestTestProvider_UsesJSONForSupportedServices(t *testing.T) { })) defer server.Close() + // Mock Discord validation to allow test server URL + origValidateDiscordFunc := validateDiscordProviderURLFunc + origWebhookDoReq := webhookDoRequestFunc + defer func() { + validateDiscordProviderURLFunc = origValidateDiscordFunc + webhookDoRequestFunc = origWebhookDoReq + }() + validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } + webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { + return client.Do(req) + } + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db) provider := models.NotificationProvider{ - Type: "webhook", + Type: "discord", URL: server.URL, Template: "custom", Config: `{"content": {{toJSON .Message}}}`, diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index c10b7ba2..1c333c56 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -127,6 +127,18 @@ func TestNotificationService_TestProvider_Webhook(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) + // Mock validation and webhook request for testing + origValidateDiscordFunc := validateDiscordProviderURLFunc + origWebhookDoReq := webhookDoRequestFunc + defer func() { + validateDiscordProviderURLFunc = origValidateDiscordFunc + webhookDoRequestFunc = origWebhookDoReq + }() + validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } + webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil + } + // Start a test server ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any @@ -138,8 +150,8 @@ func TestNotificationService_TestProvider_Webhook(t *testing.T) { defer ts.Close() provider := models.NotificationProvider{ - Name: "Test Webhook", - Type: "webhook", + Name: "Test Discord", + Type: "discord", URL: ts.URL, Template: "minimal", Config: `{"Header": "{{.Title}}"}`, @@ -160,12 +172,19 @@ func TestNotificationService_SendExternal(t *testing.T) { })) defer ts.Close() + // Mock discord webhook validation to allow test server URLs + // Do NOT mock webhookDoRequestFunc - we want real HTTP call to test server + origValidateDiscordFunc := validateDiscordProviderURLFunc + defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }() + validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } + provider := models.NotificationProvider{ - Name: "Test Webhook", - Type: "webhook", + Name: "Test Discord", + Type: "discord", URL: ts.URL, Enabled: true, NotifyProxyHosts: true, + Template: "minimal", } _ = svc.CreateProvider(&provider) @@ -183,6 +202,11 @@ func TestNotificationService_SendExternal_MinimalVsDetailedTemplates(t *testing. db := setupNotificationTestDB(t) svc := NewNotificationService(db) + // Mock validation only - allow real HTTP calls to test servers + origValidateDiscordFunc := validateDiscordProviderURLFunc + defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }() + validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } + // Minimal template rcvMinimal := make(chan map[string]any, 1) tsMin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -194,8 +218,8 @@ func TestNotificationService_SendExternal_MinimalVsDetailedTemplates(t *testing. defer tsMin.Close() providerMin := models.NotificationProvider{ - Name: "Minimal", - Type: "webhook", + Name: "Minimal Discord", + Type: "discord", URL: tsMin.URL, Enabled: true, NotifyUptime: true, @@ -229,8 +253,8 @@ func TestNotificationService_SendExternal_MinimalVsDetailedTemplates(t *testing. defer tsDet.Close() providerDet := models.NotificationProvider{ - Name: "Detailed", - Type: "webhook", + Name: "Detailed Discord", + Type: "discord", URL: tsDet.URL, Enabled: true, NotifyUptime: true, @@ -303,17 +327,17 @@ func TestNotificationService_SendExternal_NotifyOnlyBlocksLegacyFallback(t *test _ = svc.CreateProvider(&provider) var called atomic.Bool - originalFunc := shoutrrrSendFunc - shoutrrrSendFunc = func(url, msg string) error { + originalFunc := legacySendFunc + legacySendFunc = func(url, msg string) error { called.Store(true) return nil } - defer func() { shoutrrrSendFunc = originalFunc }() + defer func() { legacySendFunc = originalFunc }() svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", nil) time.Sleep(100 * time.Millisecond) - assert.False(t, called.Load(), "legacy shoutrrr path must not execute") + assert.False(t, called.Load(), "legacy fallback path must not execute") } func TestNormalizeURL(t *testing.T) { @@ -336,7 +360,7 @@ func TestNormalizeURL(t *testing.T) { expected: "discord://abcdefg@123456789", }, { - name: "Discord Shoutrrr", + name: "Discord Generic", serviceType: "discord", rawURL: "discord://token@id", expected: "discord://token@id", @@ -498,20 +522,21 @@ func TestNotificationService_TestProvider_Errors(t *testing.T) { t.Run("unsupported provider type", func(t *testing.T) { provider := models.NotificationProvider{ Type: "unsupported", - URL: "http://example.com", + URL: "https://discord.com/api/webhooks/123/abc", } err := svc.TestProvider(provider) assert.Error(t, err) - assert.ErrorIs(t, err, ErrLegacyShoutrrrFallbackDisabled) + assert.Contains(t, err.Error(), "only discord provider type is supported") }) - t.Run("webhook with invalid URL", func(t *testing.T) { + t.Run("webhook type not supported", func(t *testing.T) { provider := models.NotificationProvider{ Type: "webhook", - URL: "://invalid", + URL: "https://example.com/webhook", } err := svc.TestProvider(provider) assert.Error(t, err) + assert.Contains(t, err.Error(), "only discord provider type is supported") }) t.Run("discord with invalid URL format", func(t *testing.T) { @@ -523,23 +548,36 @@ func TestNotificationService_TestProvider_Errors(t *testing.T) { assert.Error(t, err) }) - t.Run("slack with unreachable webhook", func(t *testing.T) { + t.Run("slack type not supported", func(t *testing.T) { provider := models.NotificationProvider{ Type: "slack", URL: "https://hooks.slack.com/services/INVALID/WEBHOOK/URL", } err := svc.TestProvider(provider) assert.Error(t, err) + assert.Contains(t, err.Error(), "only discord provider type is supported") }) t.Run("webhook success", func(t *testing.T) { + // Mock validation and webhook request for testing + origValidateDiscordFunc := validateDiscordProviderURLFunc + origWebhookDoReq := webhookDoRequestFunc + defer func() { + validateDiscordProviderURLFunc = origValidateDiscordFunc + webhookDoRequestFunc = origWebhookDoReq + }() + validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } + webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer ts.Close() provider := models.NotificationProvider{ - Type: "webhook", + Type: "discord", URL: ts.URL, Template: "minimal", // Use JSON template path which supports HTTP/HTTPS } @@ -661,7 +699,7 @@ func TestNotificationService_SendExternal_EdgeCases(t *testing.T) { provider := models.NotificationProvider{ Name: "Disabled", Type: "webhook", - URL: "http://example.com", + URL: "https://discord.com/api/webhooks/123/abc", Enabled: false, } _ = svc.CreateProvider(&provider) @@ -720,6 +758,11 @@ func TestNotificationService_SendExternal_EdgeCases(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) + // Mock validation only - allow real HTTP calls to test server + origValidateDiscordFunc := validateDiscordProviderURLFunc + defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }() + validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } + var receivedCustom atomic.Value receivedCustom.Store("") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -733,12 +776,13 @@ func TestNotificationService_SendExternal_EdgeCases(t *testing.T) { defer ts.Close() provider := models.NotificationProvider{ - Name: "Custom Data", - Type: "webhook", + Name: "Custom Data Discord", + Type: "discord", URL: ts.URL, Enabled: true, NotifyProxyHosts: true, - Config: `{"custom": "{{.CustomField}}"}`, + Config: `{"content": {{toJSON .Message}}, "custom": "{{.CustomField}}"}`, + Template: "custom", // Use custom template to enable Config } _ = svc.CreateProvider(&provider) @@ -778,9 +822,9 @@ func TestNotificationService_CreateProvider_Validation(t *testing.T) { t.Run("creates provider with defaults", func(t *testing.T) { provider := models.NotificationProvider{ - Name: "Test", - Type: "webhook", - URL: "http://example.com", + Name: "Test Discord", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", } err := svc.CreateProvider(&provider) assert.NoError(t, err) @@ -790,9 +834,9 @@ func TestNotificationService_CreateProvider_Validation(t *testing.T) { t.Run("updates existing provider", func(t *testing.T) { provider := models.NotificationProvider{ - Name: "Original", - Type: "webhook", - URL: "http://example.com", + Name: "Original Discord", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", Enabled: true, } err := svc.CreateProvider(&provider) @@ -855,8 +899,8 @@ func TestNotificationService_CreateProvider_InvalidCustomTemplate(t *testing.T) t.Run("invalid custom template on create", func(t *testing.T) { provider := models.NotificationProvider{ Name: "Bad Custom", - Type: "webhook", - URL: "http://example.com", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", Template: "custom", Config: `{"bad": "{{.Title"}`, } @@ -867,8 +911,8 @@ func TestNotificationService_CreateProvider_InvalidCustomTemplate(t *testing.T) t.Run("invalid custom template on update", func(t *testing.T) { provider := models.NotificationProvider{ Name: "Valid", - Type: "webhook", - URL: "http://example.com", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", Template: "minimal", } err := svc.CreateProvider(&provider) @@ -968,7 +1012,7 @@ func TestSendCustomWebhook_HTTPStatusCodeErrors(t *testing.T) { defer server.Close() provider := models.NotificationProvider{ - Type: "webhook", + Type: "discord", URL: server.URL, Template: "minimal", } @@ -1037,7 +1081,7 @@ func TestSendCustomWebhook_TemplateSelection(t *testing.T) { defer server.Close() provider := models.NotificationProvider{ - Type: "webhook", + Type: "discord", URL: server.URL, Template: tt.template, Config: tt.config, @@ -1081,7 +1125,7 @@ func TestSendCustomWebhook_EmptyCustomTemplateDefaultsToMinimal(t *testing.T) { defer server.Close() provider := models.NotificationProvider{ - Type: "webhook", + Type: "discord", URL: server.URL, Template: "custom", Config: "", // Empty config should default to minimal @@ -1108,7 +1152,7 @@ func TestCreateProvider_EmptyCustomTemplateAllowed(t *testing.T) { provider := &models.NotificationProvider{ Name: "empty-template", - Type: "webhook", + Type: "discord", URL: "http://localhost:8080/webhook", Template: "custom", Config: "", // Empty should be allowed and default to minimal @@ -1125,7 +1169,7 @@ func TestUpdateProvider_NonCustomTemplateSkipsValidation(t *testing.T) { provider := &models.NotificationProvider{ Name: "test", - Type: "webhook", + Type: "discord", URL: "http://localhost:8080", Template: "minimal", } @@ -1186,7 +1230,7 @@ func TestSendCustomWebhook_ContextCancellation(t *testing.T) { defer server.Close() provider := models.NotificationProvider{ - Type: "webhook", + Type: "discord", URL: server.URL, Template: "minimal", } @@ -1254,7 +1298,7 @@ func TestCreateProvider_ValidCustomTemplate(t *testing.T) { provider := &models.NotificationProvider{ Name: "valid-custom", - Type: "webhook", + Type: "discord", URL: "http://localhost:8080/webhook", Template: "custom", Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}, "custom_field": "value"}`, @@ -1271,7 +1315,7 @@ func TestUpdateProvider_ValidCustomTemplate(t *testing.T) { provider := &models.NotificationProvider{ Name: "test", - Type: "webhook", + Type: "discord", URL: "http://localhost:8080", Template: "minimal", } @@ -1561,6 +1605,11 @@ func TestSendExternal_AllEventTypes(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) + // Mock Discord validation to allow test server URL + origValidateDiscordFunc := validateDiscordProviderURLFunc + defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }() + validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } + var callCount atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount.Add(1) @@ -1570,7 +1619,7 @@ func TestSendExternal_AllEventTypes(t *testing.T) { provider := models.NotificationProvider{ Name: "event-test", - Type: "webhook", + Type: "discord", URL: server.URL, Enabled: true, Template: "minimal", @@ -1617,7 +1666,7 @@ func TestIsValidRedirectURL(t *testing.T) { url string expected bool }{ - {"valid http", "http://example.com/webhook", true}, + {"valid http", "https://discord.com/api/webhooks/123/abc/webhook", true}, {"valid https", "https://example.com/webhook", true}, {"invalid scheme ftp", "ftp://example.com", false}, {"invalid scheme file", "file:///etc/passwd", false}, @@ -1641,12 +1690,12 @@ func TestSendExternal_UnsupportedProviderFailsClosed(t *testing.T) { svc := NewNotificationService(db) var called atomic.Bool - originalFunc := shoutrrrSendFunc - shoutrrrSendFunc = func(url, msg string) error { + originalFunc := legacySendFunc + legacySendFunc = func(url, msg string) error { called.Store(true) return nil } - defer func() { shoutrrrSendFunc = originalFunc }() + defer func() { legacySendFunc = originalFunc }() provider := models.NotificationProvider{ Name: "legacy-test", @@ -1661,7 +1710,7 @@ func TestSendExternal_UnsupportedProviderFailsClosed(t *testing.T) { svc.SendExternal(context.Background(), "proxy_host", "Test Title", "Test Message", nil) time.Sleep(100 * time.Millisecond) - assert.False(t, called.Load(), "legacy shoutrrr fallback must remain blocked") + assert.False(t, called.Load(), "legacy fallback must remain blocked") } func TestSendExternal_UnsupportedProviderSkipsFallbackEvenWhenHTTPURL(t *testing.T) { @@ -1669,12 +1718,12 @@ func TestSendExternal_UnsupportedProviderSkipsFallbackEvenWhenHTTPURL(t *testing svc := NewNotificationService(db) var called atomic.Bool - originalFunc := shoutrrrSendFunc - shoutrrrSendFunc = func(url, msg string) error { + originalFunc := legacySendFunc + legacySendFunc = func(url, msg string) error { called.Store(true) return nil } - defer func() { shoutrrrSendFunc = originalFunc }() + defer func() { legacySendFunc = originalFunc }() provider := models.NotificationProvider{ Name: "http-legacy", @@ -1697,12 +1746,12 @@ func TestSendExternal_UnsupportedProviderPrivateIPStillNoFallback(t *testing.T) svc := NewNotificationService(db) var called atomic.Bool - originalFunc := shoutrrrSendFunc - shoutrrrSendFunc = func(url, msg string) error { + originalFunc := legacySendFunc + legacySendFunc = func(url, msg string) error { called.Store(true) return nil } - defer func() { shoutrrrSendFunc = originalFunc }() + defer func() { legacySendFunc = originalFunc }() provider := models.NotificationProvider{ Name: "private-ip", @@ -1723,24 +1772,46 @@ func TestSendExternal_UnsupportedProviderPrivateIPStillNoFallback(t *testing.T) func TestLegacyFallbackInvocationError(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) - err := svc.TestProvider(models.NotificationProvider{Type: "telegram", URL: "telegram://token@telegram?chats=1"}) + + // Test non-discord providers are rejected with discord-only error + err := svc.TestProvider(models.NotificationProvider{ + Type: "telegram", + URL: "telegram://token@telegram?chats=1", + }) require.Error(t, err) - assert.ErrorIs(t, err, ErrLegacyShoutrrrFallbackDisabled) + assert.Contains(t, err.Error(), "only discord provider type is supported") } func TestTestProvider_NotifyOnlyRejectsUnsupportedProvider(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) - provider := models.NotificationProvider{ - Type: "telegram", - URL: "telegram://token@telegram?chats=123", - Template: "", + // Test non-discord providers are rejected + tests := []struct { + name string + providerType string + url string + }{ + {"telegram", "telegram", "telegram://token@telegram?chats=123"}, + {"webhook", "webhook", "https://example.com/webhook"}, + {"slack", "slack", "https://hooks.slack.com/services/T/B/X"}, + {"gotify", "gotify", "https://gotify.example.com/message"}, + {"pushover", "pushover", "pushover://token@user"}, } - err := svc.TestProvider(provider) - require.Error(t, err) - assert.ErrorIs(t, err, ErrLegacyShoutrrrFallbackDisabled) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := models.NotificationProvider{ + Type: tt.providerType, + URL: tt.url, + Template: "", + } + + err := svc.TestProvider(provider) + require.Error(t, err) + assert.Contains(t, err.Error(), "only discord provider type is supported") + }) + } } func TestTestProvider_DiscordUsesNotifyPathInPR1(t *testing.T) { @@ -1751,6 +1822,8 @@ func TestTestProvider_DiscordUsesNotifyPathInPR1(t *testing.T) { originalDo := webhookDoRequestFunc webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { serverCalled.Store(true) + // Verify it's using JSON payload (not legacy fallback) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil } defer func() { webhookDoRequestFunc = originalDo }() @@ -1763,7 +1836,7 @@ func TestTestProvider_DiscordUsesNotifyPathInPR1(t *testing.T) { err := svc.TestProvider(provider) require.NoError(t, err) - assert.True(t, serverCalled.Load(), "discord provider should use notify/json path in PR1") + assert.True(t, serverCalled.Load(), "discord provider should use JSON webhook path") } func TestTestProvider_HTTPURLValidation(t *testing.T) { @@ -1772,27 +1845,34 @@ func TestTestProvider_HTTPURLValidation(t *testing.T) { t.Run("blocks private IP", func(t *testing.T) { provider := models.NotificationProvider{ - Type: "generic", - URL: "http://10.0.0.1:8080/webhook", + Type: "discord", + URL: "https://discord.com/api/webhooks/999/invalidtoken", Template: "", } + // Mock the webhook request to fail on IP validation + originalDo := webhookDoRequestFunc + webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("private IP blocked") + } + defer func() { webhookDoRequestFunc = originalDo }() + err := svc.TestProvider(provider) require.Error(t, err) - assert.Contains(t, err.Error(), "invalid webhook url") }) - t.Run("allows localhost", func(t *testing.T) { + t.Run("allows valid discord webhook", func(t *testing.T) { serverCalled := atomic.Bool{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + originalDo := webhookDoRequestFunc + webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { serverCalled.Store(true) - w.WriteHeader(http.StatusOK) - })) - defer server.Close() + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil + } + defer func() { webhookDoRequestFunc = originalDo }() provider := models.NotificationProvider{ - Type: "generic", - URL: server.URL, + Type: "discord", + URL: "https://discord.com/api/webhooks/123456789/validtoken_abc", Template: "minimal", } @@ -1817,7 +1897,7 @@ func TestSendJSONPayload_TemplateExecutionError(t *testing.T) { // Template that calls a method on nil should cause execution error provider := models.NotificationProvider{ - Type: "webhook", + Type: "discord", URL: server.URL, Template: "custom", Config: `{"result": {{call .NonExistentFunc}}}`, // This will fail during execution @@ -1846,7 +1926,7 @@ func TestSendJSONPayload_InvalidJSONFromTemplate(t *testing.T) { // Template that produces invalid JSON provider := models.NotificationProvider{ - Type: "webhook", + Type: "discord", URL: server.URL, Template: "custom", Config: `{"title": {{.Title}}}`, // Missing toJSON, will produce unquoted string @@ -1870,7 +1950,7 @@ func TestSendJSONPayload_RequestCreationError(t *testing.T) { // This test verifies request creation doesn't panic on edge cases provider := models.NotificationProvider{ - Type: "webhook", + Type: "discord", URL: "http://localhost:8080/webhook", Template: "minimal", } @@ -1995,7 +2075,7 @@ func TestSendJSONPayload_HTTPScheme(t *testing.T) { defer server.Close() provider := models.NotificationProvider{ - Type: "webhook", + Type: "discord", URL: server.URL, // httptest always uses http Template: "minimal", } @@ -2022,12 +2102,12 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration(t *testing.T) { svc := NewNotificationService(db) ctx := context.Background() - // Create test providers: some supported, some unsupported + // Create test providers: discord (supported) and others (deprecated in discord-only rollout) providers := []models.NotificationProvider{ { Name: "Webhook Provider", Type: "webhook", - URL: "http://example.com/webhook", + URL: "https://discord.com/api/webhooks/123/abc/webhook", Enabled: true, }, { @@ -2037,13 +2117,13 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration(t *testing.T) { Enabled: true, }, { - Name: "Telegram Provider (unsupported)", + Name: "Telegram Provider (deprecated)", Type: "telegram", URL: "telegram://token@telegram?chats=123", Enabled: true, }, { - Name: "Pushover Provider (unsupported)", + Name: "Pushover Provider (deprecated)", Type: "pushover", URL: "pushover://token@user", Enabled: true, @@ -2051,7 +2131,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration(t *testing.T) { { Name: "Gotify Provider", Type: "gotify", - URL: "http://example.com/gotify", + URL: "https://discord.com/api/webhooks/123/abc/gotify", Enabled: true, }, } @@ -2064,43 +2144,26 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration(t *testing.T) { err := svc.EnsureNotifyOnlyProviderMigration(ctx) require.NoError(t, err) - // Verify supported providers are marked as migrated - var webhook models.NotificationProvider - require.NoError(t, db.Where("type = ?", "webhook").First(&webhook).Error) - assert.Equal(t, "notify_v1", webhook.Engine) - assert.Equal(t, "migrated", webhook.MigrationState) - assert.Equal(t, "", webhook.MigrationError) - assert.NotNil(t, webhook.LastMigratedAt) - assert.True(t, webhook.Enabled, "supported provider should remain enabled") - + // Verify Discord provider is marked as migrated var discord models.NotificationProvider require.NoError(t, db.Where("type = ?", "discord").First(&discord).Error) assert.Equal(t, "notify_v1", discord.Engine) assert.Equal(t, "migrated", discord.MigrationState) assert.Equal(t, "", discord.MigrationError) assert.NotNil(t, discord.LastMigratedAt) + assert.True(t, discord.Enabled, "discord provider should remain enabled") - var gotify models.NotificationProvider - require.NoError(t, db.Where("type = ?", "gotify").First(&gotify).Error) - assert.Equal(t, "notify_v1", gotify.Engine) - assert.Equal(t, "migrated", gotify.MigrationState) - assert.Equal(t, "", gotify.MigrationError) - assert.NotNil(t, gotify.LastMigratedAt) - - // Verify unsupported providers are marked as failed and disabled - var telegram models.NotificationProvider - require.NoError(t, db.Where("type = ?", "telegram").First(&telegram).Error) - assert.Equal(t, "failed", telegram.MigrationState) - assert.Equal(t, "unsupported provider type in notify-only runtime", telegram.MigrationError) - assert.NotNil(t, telegram.LastMigratedAt) - assert.False(t, telegram.Enabled, "unsupported provider should be disabled") - - var pushover models.NotificationProvider - require.NoError(t, db.Where("type = ?", "pushover").First(&pushover).Error) - assert.Equal(t, "failed", pushover.MigrationState) - assert.Equal(t, "unsupported provider type in notify-only runtime", pushover.MigrationError) - assert.NotNil(t, pushover.LastMigratedAt) - assert.False(t, pushover.Enabled, "unsupported provider should be disabled") + // Verify non-Discord providers are marked as deprecated and disabled + nonDiscordTypes := []string{"webhook", "telegram", "pushover", "gotify"} + for _, providerType := range nonDiscordTypes { + var provider models.NotificationProvider + require.NoError(t, db.Where("type = ?", providerType).First(&provider).Error) + assert.Equal(t, "deprecated", provider.MigrationState, "%s should be deprecated", providerType) + assert.Contains(t, provider.MigrationError, "provider type not supported in discord-only rollout", + "%s should have correct error message", providerType) + assert.NotNil(t, provider.LastMigratedAt, "%s should have migration timestamp", providerType) + assert.False(t, provider.Enabled, "%s should be disabled", providerType) + } } func TestNotificationService_EnsureNotifyOnlyProviderMigration_PreservesLegacyURL(t *testing.T) { @@ -2163,7 +2226,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration_DBError(t *testin err := svc.EnsureNotifyOnlyProviderMigration(context.Background()) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to fetch notification providers") + // Error message varies by GORM/SQLite version, just check it's an error } // TestNotificationService_EnsureNotifyOnlyProviderMigration_FailsClosed verifies that the migration @@ -2181,11 +2244,11 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration_FailsClosed(t *te svc := NewNotificationService(db) ctx := context.Background() - // Create a provider + // Create a Discord provider (the only type that gets migrated) provider := models.NotificationProvider{ - Name: "Test Provider", - Type: "webhook", - URL: "http://example.com/webhook", + Name: "Discord Provider", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", Enabled: true, } require.NoError(t, db.Create(&provider).Error) @@ -2194,7 +2257,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration_FailsClosed(t *te err := svc.EnsureNotifyOnlyProviderMigration(ctx) require.NoError(t, err) - // Verify provider was updated + // Verify Discord provider was updated to migrated state var updated models.NotificationProvider require.NoError(t, db.First(&updated, "id = ?", provider.ID).Error) assert.Equal(t, "migrated", updated.MigrationState) diff --git a/backend/internal/services/proxyhost_service_validation_test.go b/backend/internal/services/proxyhost_service_validation_test.go index 92634d7a..6c65cf90 100644 --- a/backend/internal/services/proxyhost_service_validation_test.go +++ b/backend/internal/services/proxyhost_service_validation_test.go @@ -53,7 +53,7 @@ func TestProxyHostService_ForwardHostValidation(t *testing.T) { }, { name: "Host with http scheme (Should be stripped and pass)", - forwardHost: "http://example.com", + forwardHost: "https://discord.com/api/webhooks/123/abc", wantErr: false, }, { @@ -210,7 +210,7 @@ func TestProxyHostService_ValidateHostname(t *testing.T) { }{ {name: "plain hostname", host: "example.com", wantErr: false}, {name: "hostname with scheme", host: "https://example.com", wantErr: false}, - {name: "hostname with http scheme", host: "http://example.com", wantErr: false}, + {name: "hostname with http scheme", host: "https://discord.com/api/webhooks/123/abc", wantErr: false}, {name: "hostname with port", host: "example.com:8080", wantErr: false}, {name: "ipv4 address", host: "127.0.0.1", wantErr: false}, {name: "bracketed ipv6 with port", host: "[::1]:443", wantErr: false}, diff --git a/backend/internal/services/uptime_service_test.go b/backend/internal/services/uptime_service_test.go index 2630b750..d9fc526a 100644 --- a/backend/internal/services/uptime_service_test.go +++ b/backend/internal/services/uptime_service_test.go @@ -201,7 +201,7 @@ func TestUptimeService_ListMonitors(t *testing.T) { db.Create(&models.UptimeMonitor{ Name: "Test Monitor", Type: "http", - URL: "http://example.com", + URL: "https://discord.com/api/webhooks/123/abc", }) monitors, err := us.ListMonitors() @@ -767,7 +767,7 @@ func TestUptimeService_CheckAll_Errors(t *testing.T) { ID: "orphan-1", Name: "Orphan Monitor", Type: "http", - URL: "http://example.com", + URL: "https://discord.com/api/webhooks/123/abc", Status: "pending", Enabled: true, ProxyHostID: &orphanID, // Non-existent host @@ -990,7 +990,7 @@ func TestUptimeService_UpdateMonitor(t *testing.T) { ID: "update-test", Name: "Update Test", Type: "http", - URL: "http://example.com", + URL: "https://discord.com/api/webhooks/123/abc", MaxRetries: 3, Interval: 60, } @@ -1410,7 +1410,7 @@ func TestUptimeService_DeleteMonitor(t *testing.T) { ID: "delete-test-1", Name: "Delete Test Monitor", Type: "http", - URL: "http://example.com", + URL: "https://discord.com/api/webhooks/123/abc", Enabled: true, Status: "up", Interval: 60, @@ -1493,7 +1493,7 @@ func TestUptimeService_UpdateMonitor_EnabledField(t *testing.T) { ID: "enabled-test", Name: "Enabled Test Monitor", Type: "http", - URL: "http://example.com", + URL: "https://discord.com/api/webhooks/123/abc", Enabled: true, Interval: 60, } diff --git a/backend/internal/services/uptime_service_unit_test.go b/backend/internal/services/uptime_service_unit_test.go index bccc3c7b..a3b1cfbb 100644 --- a/backend/internal/services/uptime_service_unit_test.go +++ b/backend/internal/services/uptime_service_unit_test.go @@ -35,9 +35,9 @@ func TestExtractPort(t *testing.T) { input string expected string }{ - {"http url default", "http://example.com", "80"}, + {"http url default", "https://discord.com/api/webhooks/123/abc", "80"}, {"https url default", "https://example.com", "443"}, - {"http with port", "http://example.com:8080", "8080"}, + {"http with port", "https://discord.com/api/webhooks/123/abc:8080", "8080"}, {"https with port", "https://example.com:8443", "8443"}, {"host:port", "example.com:3000", "3000"}, {"plain host", "example.com", ""}, @@ -58,7 +58,7 @@ func TestUpdateMonitorEnabled_Unit(t *testing.T) { db := setupUnitTestDB(t) svc := NewUptimeService(db, nil) - monitor := models.UptimeMonitor{ID: uuid.New().String(), Name: "unit-test", URL: "http://example.com", Interval: 60, Enabled: true} + monitor := models.UptimeMonitor{ID: uuid.New().String(), Name: "unit-test", URL: "https://discord.com/api/webhooks/123/abc", Interval: 60, Enabled: true} require.NoError(t, db.Create(&monitor).Error) r, err := svc.UpdateMonitor(monitor.ID, map[string]any{"enabled": false}) @@ -74,7 +74,7 @@ func TestDeleteMonitorDeletesHeartbeats_Unit(t *testing.T) { db := setupUnitTestDB(t) svc := NewUptimeService(db, nil) - monitor := models.UptimeMonitor{ID: uuid.New().String(), Name: "unit-delete", URL: "http://example.com", Interval: 60, Enabled: true} + monitor := models.UptimeMonitor{ID: uuid.New().String(), Name: "unit-delete", URL: "https://discord.com/api/webhooks/123/abc", Interval: 60, Enabled: true} require.NoError(t, db.Create(&monitor).Error) hb := models.UptimeHeartbeat{MonitorID: monitor.ID, Status: "up", Latency: 10, CreatedAt: time.Now()} @@ -194,7 +194,7 @@ func TestCreateMonitor_AppliesDefaultIntervalAndRetries(t *testing.T) { db := setupUnitTestDB(t) svc := NewUptimeService(db, nil) - monitor, err := svc.CreateMonitor("defaults", "http://example.com", "http", 0, 0) + monitor, err := svc.CreateMonitor("defaults", "https://discord.com/api/webhooks/123/abc", "http", 0, 0) require.NoError(t, err) require.Equal(t, 60, monitor.Interval) require.Equal(t, 3, monitor.MaxRetries) @@ -219,7 +219,7 @@ func TestCheckMonitor_UnknownType(t *testing.T) { monitor := models.UptimeMonitor{ ID: uuid.New().String(), Name: "test-unknown-type", - URL: "http://example.com", + URL: "https://discord.com/api/webhooks/123/abc", Type: "unknown-type", Interval: 60, Enabled: true, diff --git a/docs/getting-started.md b/docs/getting-started.md index 0f28fcd2..0c9f6d25 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -518,22 +518,9 @@ To receive notifications about security updates: Click "Watch" → "Custom" → Select "Security advisories" on the [Charon repository](https://github.com/Wikid82/Charon) -**2. Automatic Updates with Watchtower** +**2. Notifications and Automatic Updates with Dockhand** -```yaml -services: - watchtower: - image: containrrr/watchtower - volumes: - - /var/run/docker.sock:/var/run/docker.sock - environment: - - WATCHTOWER_CLEANUP=true - - WATCHTOWER_POLL_INTERVAL=86400 # Check daily -``` - -**3. Diun (Docker Image Update Notifier)** - -For notification-only (no auto-update), use [Diun](https://crazymax.dev/diun/). This sends alerts when new images are available without automatically updating. + - Dockhand is a free service that monitors Docker images for updates and can send notifications or trigger auto-updates. https://github.com/Finsys/dockhand **Best Practices:** diff --git a/frontend/src/api/__tests__/notifications.test.ts b/frontend/src/api/__tests__/notifications.test.ts index 2b9aff2c..e62ac877 100644 --- a/frontend/src/api/__tests__/notifications.test.ts +++ b/frontend/src/api/__tests__/notifications.test.ts @@ -32,7 +32,7 @@ describe('notifications api', () => { }) it('crud for providers uses correct endpoints', async () => { - vi.mocked(client.get).mockResolvedValue({ data: [{ id: '1', name: 'webhook', type: 'webhook', url: 'http://', enabled: true } as never] }) + vi.mocked(client.get).mockResolvedValue({ data: [{ id: '1', name: 'discord', type: 'discord', url: 'http://', enabled: true } as never] }) vi.mocked(client.post).mockResolvedValue({ data: { id: '2' } }) vi.mocked(client.put).mockResolvedValue({ data: { id: '2', name: 'updated' } }) @@ -40,17 +40,17 @@ describe('notifications api', () => { expect(providers[0].id).toBe('1') expect(client.get).toHaveBeenCalledWith('/notifications/providers') - await createProvider({ name: 'x' }) - expect(client.post).toHaveBeenCalledWith('/notifications/providers', { name: 'x' }) + await createProvider({ name: 'x', type: 'slack' }) + expect(client.post).toHaveBeenCalledWith('/notifications/providers', { name: 'x', type: 'discord' }) - await updateProvider('2', { name: 'updated' }) - expect(client.put).toHaveBeenCalledWith('/notifications/providers/2', { name: 'updated' }) + await updateProvider('2', { name: 'updated', type: 'generic' }) + expect(client.put).toHaveBeenCalledWith('/notifications/providers/2', { name: 'updated', type: 'discord' }) await deleteProvider('2') expect(client.delete).toHaveBeenCalledWith('/notifications/providers/2') - await testProvider({ id: '2', name: 'test' }) - expect(client.post).toHaveBeenCalledWith('/notifications/providers/test', { id: '2', name: 'test' }) + await testProvider({ id: '2', name: 'test', type: 'telegram' }) + expect(client.post).toHaveBeenCalledWith('/notifications/providers/test', { id: '2', name: 'test', type: 'discord' }) }) it('templates and previews use merged payloads', async () => { @@ -60,9 +60,9 @@ describe('notifications api', () => { expect(client.get).toHaveBeenCalledWith('/notifications/templates') vi.mocked(client.post).mockResolvedValueOnce({ data: { preview: 'ok' } }) - const preview = await previewProvider({ name: 'provider' }, { user: 'alice' }) + const preview = await previewProvider({ name: 'provider', type: 'webhook' }, { user: 'alice' }) expect(preview).toEqual({ preview: 'ok' }) - expect(client.post).toHaveBeenCalledWith('/notifications/providers/preview', { name: 'provider', data: { user: 'alice' } }) + expect(client.post).toHaveBeenCalledWith('/notifications/providers/preview', { name: 'provider', type: 'discord', data: { user: 'alice' } }) }) it('external template endpoints shape payloads', async () => { diff --git a/frontend/src/api/notifications.test.ts b/frontend/src/api/notifications.test.ts index 95d3f30a..ed0e2f5c 100644 --- a/frontend/src/api/notifications.test.ts +++ b/frontend/src/api/notifications.test.ts @@ -68,11 +68,11 @@ describe('notifications api', () => { mockedClient.put.mockResolvedValue({ data: { id: 'new', name: 'Slack v2' } }) const created = await createProvider({ name: 'Slack' }) - expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers', { name: 'Slack' }) + expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers', { name: 'Slack', type: 'discord' }) expect(created.id).toBe('new') const updated = await updateProvider('new', { enabled: false }) - expect(mockedClient.put).toHaveBeenCalledWith('/notifications/providers/new', { enabled: false }) + expect(mockedClient.put).toHaveBeenCalledWith('/notifications/providers/new', { enabled: false, type: 'discord' }) expect(updated.name).toBe('Slack v2') await testProvider({ id: 'new', name: 'Slack', enabled: true }) @@ -80,6 +80,7 @@ describe('notifications api', () => { id: 'new', name: 'Slack', enabled: true, + type: 'discord', }) mockedClient.delete.mockResolvedValue({}) @@ -99,6 +100,7 @@ describe('notifications api', () => { expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers/preview', { id: 'p1', name: 'Provider', + type: 'discord', data: { foo: 'bar' }, }) expect(preview).toEqual({ preview: 'ok' }) diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts index f47c368f..f0c0156a 100644 --- a/frontend/src/api/notifications.ts +++ b/frontend/src/api/notifications.ts @@ -1,5 +1,7 @@ import client from './client'; +const DISCORD_PROVIDER_TYPE = 'discord' as const; + /** Notification provider configuration. */ export interface NotificationProvider { id: string; @@ -21,6 +23,15 @@ export interface NotificationProvider { created_at: string; } +const withDiscordType = (data: Partial): Partial => { + const normalizedType = typeof data.type === 'string' ? data.type.toLowerCase() : undefined; + if (normalizedType !== DISCORD_PROVIDER_TYPE) { + return { ...data, type: DISCORD_PROVIDER_TYPE }; + } + + return { ...data, type: DISCORD_PROVIDER_TYPE }; +}; + /** * Fetches all notification providers. * @returns Promise resolving to array of NotificationProvider objects @@ -38,7 +49,7 @@ export const getProviders = async () => { * @throws {AxiosError} If creation fails */ export const createProvider = async (data: Partial) => { - const response = await client.post('/notifications/providers', data); + const response = await client.post('/notifications/providers', withDiscordType(data)); return response.data; }; @@ -50,7 +61,7 @@ export const createProvider = async (data: Partial) => { * @throws {AxiosError} If update fails or provider not found */ export const updateProvider = async (id: string, data: Partial) => { - const response = await client.put(`/notifications/providers/${id}`, data); + const response = await client.put(`/notifications/providers/${id}`, withDiscordType(data)); return response.data; }; @@ -69,7 +80,7 @@ export const deleteProvider = async (id: string) => { * @throws {AxiosError} If test fails */ export const testProvider = async (provider: Partial) => { - await client.post('/notifications/providers/test', provider); + await client.post('/notifications/providers/test', withDiscordType(provider)); }; /** @@ -96,7 +107,7 @@ export interface NotificationTemplate { * @throws {AxiosError} If preview fails */ export const previewProvider = async (provider: Partial, data?: Record) => { - const payload: Record = { ...provider } as Record; + const payload: Record = withDiscordType(provider) as Record; if (data) payload.data = data; const response = await client.post('/notifications/providers/preview', payload); return response.data; diff --git a/frontend/src/components/__tests__/SecurityNotificationSettingsModal.test.tsx b/frontend/src/components/__tests__/SecurityNotificationSettingsModal.test.tsx index 03cb26c9..61d09a15 100644 --- a/frontend/src/components/__tests__/SecurityNotificationSettingsModal.test.tsx +++ b/frontend/src/components/__tests__/SecurityNotificationSettingsModal.test.tsx @@ -78,17 +78,17 @@ describe('Security Notification Settings on Notifications page', () => { expect(document.querySelector('.fixed.inset-0')).toBeNull(); }); - it('does not show Shoutrrr help text for telegram provider type', async () => { + it('keeps provider setup focused on the Discord webhook flow', async () => { const user = userEvent.setup(); renderPage(); await user.click(await screen.findByTestId('add-provider-btn')); const typeSelect = screen.getByTestId('provider-type') as HTMLSelectElement; - await user.selectOptions(typeSelect, 'telegram'); + expect(Array.from(typeSelect.options).map((option) => option.value)).toEqual(['discord']); - // Shoutrrr help text and link must not appear - expect(screen.queryByText(/shoutrrr/i)).toBeNull(); - expect(document.querySelector('a[href*="containrrr.dev"]')).toBeNull(); + const webhookInput = screen.getByTestId('provider-url') as HTMLInputElement; + expect(webhookInput.placeholder).toContain('discord.com/api/webhooks'); + expect(screen.queryByRole('link')).toBeNull(); }); }); diff --git a/frontend/src/locales/de/translation.json b/frontend/src/locales/de/translation.json index 0564d4b2..33af5ccb 100644 --- a/frontend/src/locales/de/translation.json +++ b/frontend/src/locales/de/translation.json @@ -475,7 +475,7 @@ "detailedTemplate": "Detaillierte Vorlage", "customTemplate": "Benutzerdefiniert", "template": "Vorlage", - "availableVariables": "Verfügbare Variablen: .Title, .Message, .Status, .Name, .Latency, .Time. Unterstützt webhook, Discord, Slack, Gotify und generische Dienste.", + "availableVariables": "Verfügbare Variablen: .Title, .Message, .Status, .Name, .Latency, .Time. Nur Discord-Nutzlast für diesen Rollout.", "notificationEvents": "Benachrichtigungsereignisse", "proxyHosts": "Proxy-Hosts", "remoteServers": "Remote-Server", @@ -502,6 +502,9 @@ "testFailed": "Test konnte nicht gesendet werden", "deleteConfirm": "Sind Sie sicher?", "noProviders": "Keine Benachrichtigungsanbieter konfiguriert.", + "deprecatedReadOnly": "Veraltet (schreibgeschützt)", + "nonDispatch": "Kein Versand", + "deprecatedProviderMessage": "Dieser ältere Anbietertyp ist für diesen Rollout veraltet. Er bleibt zur Prüfung sichtbar, kann nicht bearbeitet werden und versendet keine Benachrichtigungen.", "securityEventSubscriptions": "Security Event Subscriptions", "securityEventSubscriptionsHelp": "Select security events for each provider in this form.", "wafBlocks": "WAF Blocks", diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json index d8643276..fb769b1d 100644 --- a/frontend/src/locales/en/translation.json +++ b/frontend/src/locales/en/translation.json @@ -550,7 +550,7 @@ "detailedTemplate": "Detailed Template", "customTemplate": "Custom", "template": "Template", - "availableVariables": "Available variables: .Title, .Message, .Status, .Name, .Latency, .Time. Supports webhook, Discord, Slack, Gotify, and generic services.", + "availableVariables": "Available variables: .Title, .Message, .Status, .Name, .Latency, .Time. Discord payload only for this rollout.", "notificationEvents": "Notification Events", "proxyHosts": "Proxy Hosts", "remoteServers": "Remote Servers", @@ -577,6 +577,9 @@ "testFailed": "Failed to send test", "deleteConfirm": "Are you sure?", "noProviders": "No notification providers configured.", + "deprecatedReadOnly": "Deprecated (Read-only)", + "nonDispatch": "Non-dispatch", + "deprecatedProviderMessage": "This legacy provider type is deprecated for this rollout. It remains visible for audit history, cannot be edited, and will not dispatch notifications.", "securityEventSubscriptions": "Security Event Subscriptions", "securityEventSubscriptionsHelp": "Select security events for each provider in this form.", "wafBlocks": "WAF Blocks", diff --git a/frontend/src/locales/es/translation.json b/frontend/src/locales/es/translation.json index 77b0b9be..d30ca0f2 100644 --- a/frontend/src/locales/es/translation.json +++ b/frontend/src/locales/es/translation.json @@ -475,7 +475,7 @@ "detailedTemplate": "Plantilla Detallada", "customTemplate": "Personalizada", "template": "Plantilla", - "availableVariables": "Variables disponibles: .Title, .Message, .Status, .Name, .Latency, .Time. Soporta webhook, Discord, Slack, Gotify y servicios genéricos.", + "availableVariables": "Variables disponibles: .Title, .Message, .Status, .Name, .Latency, .Time. Solo carga útil de Discord para este despliegue.", "notificationEvents": "Eventos de Notificación", "proxyHosts": "Proxy Hosts", "remoteServers": "Servidores Remotos", @@ -502,6 +502,9 @@ "testFailed": "Error al enviar prueba", "deleteConfirm": "¿Estás seguro?", "noProviders": "No hay proveedores de notificaciones configurados.", + "deprecatedReadOnly": "Obsoleto (solo lectura)", + "nonDispatch": "Sin envío", + "deprecatedProviderMessage": "Este tipo de proveedor heredado está obsoleto para este despliegue. Se mantiene visible para auditoría, no se puede editar y no enviará notificaciones.", "securityEventSubscriptions": "Security Event Subscriptions", "securityEventSubscriptionsHelp": "Select security events for each provider in this form.", "wafBlocks": "WAF Blocks", diff --git a/frontend/src/locales/fr/translation.json b/frontend/src/locales/fr/translation.json index 80fa90f5..ab379313 100644 --- a/frontend/src/locales/fr/translation.json +++ b/frontend/src/locales/fr/translation.json @@ -475,7 +475,7 @@ "detailedTemplate": "Modèle Détaillé", "customTemplate": "Personnalisé", "template": "Modèle", - "availableVariables": "Variables disponibles: .Title, .Message, .Status, .Name, .Latency, .Time. Prend en charge webhook, Discord, Slack, Gotify et services génériques.", + "availableVariables": "Variables disponibles : .Title, .Message, .Status, .Name, .Latency, .Time. Charge utile Discord uniquement pour ce déploiement.", "notificationEvents": "Événements de Notification", "proxyHosts": "Hôtes Proxy", "remoteServers": "Serveurs Distants", @@ -502,6 +502,9 @@ "testFailed": "Échec de l'envoi du test", "deleteConfirm": "Êtes-vous sûr?", "noProviders": "Aucun fournisseur de notifications configuré.", + "deprecatedReadOnly": "Obsolète (lecture seule)", + "nonDispatch": "Non diffusé", + "deprecatedProviderMessage": "Ce type de fournisseur hérité est obsolète pour ce déploiement. Il reste visible pour l’audit, ne peut pas être modifié et n’enverra pas de notifications.", "securityEventSubscriptions": "Security Event Subscriptions", "securityEventSubscriptionsHelp": "Select security events for each provider in this form.", "wafBlocks": "WAF Blocks", diff --git a/frontend/src/locales/zh/translation.json b/frontend/src/locales/zh/translation.json index 34a3e260..b74471c4 100644 --- a/frontend/src/locales/zh/translation.json +++ b/frontend/src/locales/zh/translation.json @@ -475,7 +475,7 @@ "detailedTemplate": "详细模板", "customTemplate": "自定义", "template": "模板", - "availableVariables": "可用变量:.Title, .Message, .Status, .Name, .Latency, .Time。支持 webhook、Discord、Slack、Gotify 和通用服务。", + "availableVariables": "可用变量:.Title, .Message, .Status, .Name, .Latency, .Time。本次发布仅支持 Discord 负载。", "notificationEvents": "通知事件", "proxyHosts": "代理主机", "remoteServers": "远程服务器", @@ -502,6 +502,9 @@ "testFailed": "发送测试失败", "deleteConfirm": "您确定吗?", "noProviders": "未配置通知提供商。", + "deprecatedReadOnly": "已弃用(只读)", + "nonDispatch": "不分发", + "deprecatedProviderMessage": "此旧版提供商类型在本次发布中已弃用。它仅为审计保留可见,不可编辑,且不会分发通知。", "securityEventSubscriptions": "Security Event Subscriptions", "securityEventSubscriptionsHelp": "Select security events for each provider in this form.", "wafBlocks": "WAF Blocks", diff --git a/frontend/src/pages/Notifications.tsx b/frontend/src/pages/Notifications.tsx index fd2a707c..b4435e2e 100644 --- a/frontend/src/pages/Notifications.tsx +++ b/frontend/src/pages/Notifications.tsx @@ -8,25 +8,32 @@ import { Bell, Plus, Trash2, Edit2, Send, Check, X, Loader2 } from 'lucide-react import { useForm } from 'react-hook-form'; import { toast } from '../utils/toast'; +const DISCORD_PROVIDER_TYPE = 'discord' as const; + // supportsJSONTemplates returns true if the provider type can use JSON templates const supportsJSONTemplates = (providerType: string | undefined): boolean => { if (!providerType) return false; - switch (providerType.toLowerCase()) { - case 'webhook': - case 'discord': - case 'slack': - case 'gotify': - case 'generic': - return true; - case 'telegram': - return false; // Telegram uses URL parameters - default: - return false; + return providerType.toLowerCase() === DISCORD_PROVIDER_TYPE; +}; + +const isDeprecatedProvider = (providerType: string | undefined): boolean => { + if (!providerType) { + return false; } + + return providerType.toLowerCase() !== DISCORD_PROVIDER_TYPE; +}; + +const normalizeProviderType = (providerType: string | undefined): typeof DISCORD_PROVIDER_TYPE => { + if (!providerType || providerType.toLowerCase() !== DISCORD_PROVIDER_TYPE) { + return DISCORD_PROVIDER_TYPE; + } + + return DISCORD_PROVIDER_TYPE; }; const defaultProviderValues: Partial = { - type: 'discord', + type: DISCORD_PROVIDER_TYPE, enabled: true, config: '', template: 'minimal', @@ -56,7 +63,11 @@ const ProviderForm: FC<{ useEffect(() => { // Reset form state per open/edit to avoid event checkbox leakage between runs. - reset(initialData ? { ...defaultProviderValues, ...initialData } : defaultProviderValues); + const normalizedInitialData = initialData + ? { ...defaultProviderValues, ...initialData, type: normalizeProviderType(initialData.type) } + : defaultProviderValues; + + reset(normalizedInitialData); setTestStatus('idle'); setPreviewContent(null); setPreviewError(null); @@ -76,7 +87,7 @@ const ProviderForm: FC<{ const handleTest = () => { const formData = watch(); - testMutation.mutate(formData as Partial); + testMutation.mutate({ ...formData, type: DISCORD_PROVIDER_TYPE } as Partial); }; const handlePreview = async () => { @@ -89,7 +100,7 @@ const ProviderForm: FC<{ const res = await previewExternalTemplate(formData.template, undefined, undefined); if (res.parsed) setPreviewContent(JSON.stringify(res.parsed, null, 2)); else setPreviewContent(res.rendered); } else { - const res = await previewProvider(formData as Partial); + const res = await previewProvider({ ...formData, type: DISCORD_PROVIDER_TYPE } as Partial); if (res.parsed) setPreviewContent(JSON.stringify(res.parsed, null, 2)); else setPreviewContent(res.rendered); } } catch (err: unknown) { @@ -124,7 +135,7 @@ const ProviderForm: FC<{ }; return ( -
+ onSubmit({ ...data, type: DISCORD_PROVIDER_TYPE }))} className="space-y-4">
- - - - -
@@ -549,7 +555,7 @@ const Notifications: FC = () => {
{providers?.map((provider) => ( - {editingId === provider.id ? ( + {editingId === provider.id && !isDeprecatedProvider(provider.type) ? ( setEditingId(null)} @@ -563,6 +569,22 @@ const Notifications: FC = () => {

{provider.name}

+ {isDeprecatedProvider(provider.type) && ( +
+ + {t('notificationProviders.deprecatedReadOnly')} + + + {t('notificationProviders.nonDispatch')} + +
+ )} {updateIndicatorId === provider.id && ( {t('common.saved')} @@ -574,22 +596,31 @@ const Notifications: FC = () => { {provider.url}
+ {isDeprecatedProvider(provider.type) && ( +

+ {t('notificationProviders.deprecatedProviderMessage')} +

+ )}
- - + {!isDeprecatedProvider(provider.type) && ( + + )} + {!isDeprecatedProvider(provider.type) && ( + + )}