diff --git a/.github/skills/test-backend-unit-scripts/run.sh b/.github/skills/test-backend-unit-scripts/run.sh index c763b53b..8451ebd8 100755 --- a/.github/skills/test-backend-unit-scripts/run.sh +++ b/.github/skills/test-backend-unit-scripts/run.sh @@ -11,10 +11,13 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Helper scripts are in .github/skills/scripts/ SKILLS_SCRIPTS_DIR="$(cd "${SCRIPT_DIR}/../scripts" && pwd)" +# shellcheck disable=SC1091 # shellcheck source=../scripts/_logging_helpers.sh source "${SKILLS_SCRIPTS_DIR}/_logging_helpers.sh" +# shellcheck disable=SC1091 # shellcheck source=../scripts/_error_handling_helpers.sh source "${SKILLS_SCRIPTS_DIR}/_error_handling_helpers.sh" +# shellcheck disable=SC1091 # shellcheck source=../scripts/_environment_helpers.sh source "${SKILLS_SCRIPTS_DIR}/_environment_helpers.sh" diff --git a/backend/internal/services/enhanced_security_notification_service_patch_coverage_test.go b/backend/internal/services/enhanced_security_notification_service_patch_coverage_test.go index f5ff81e4..50129b05 100644 --- a/backend/internal/services/enhanced_security_notification_service_patch_coverage_test.go +++ b/backend/internal/services/enhanced_security_notification_service_patch_coverage_test.go @@ -2,8 +2,10 @@ package services import ( "context" + "fmt" "net/http" "net/http/httptest" + "path/filepath" "testing" "time" @@ -330,3 +332,429 @@ func TestEnhancedService_SendViaProviders_Non2xxResponse(t *testing.T) { // Service logs error but doesn't fail - continues to next provider assert.NoError(t, err) } + +func TestEnhancedService_GetProviderAggregatedConfig_CrowdSecFlag(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Setting{})) + + db.Create(&models.Setting{Key: "feature.notifications.security_provider_events.enabled", Value: "true", Type: "bool"}) + db.Create(&models.NotificationProvider{ + ID: "discord-crowdsec", + Name: "Discord CrowdSec", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", + Enabled: true, + ManagedLegacySecurity: true, + NotifySecurityCrowdSecDecisions: true, + }) + + service := NewEnhancedSecurityNotificationService(db) + config, err := service.GetSettings() + require.NoError(t, err) + assert.True(t, config.NotifyCrowdSecDecisions) +} + +func TestEnhancedService_UpdateManagedProviders_SlackDestinationContributesToAmbiguity(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.NotificationConfig{}, &models.Setting{})) + + service := NewEnhancedSecurityNotificationService(db) + err = service.updateManagedProviders(&models.NotificationConfig{ + DiscordWebhookURL: "https://discord.com/api/webhooks/123/abc", + SlackWebhookURL: "https://hooks.slack.com/services/T/B/X", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "ambiguous destination") +} + +func TestEnhancedService_UpdateManagedProviders_QueryManagedProvidersError(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.NotificationConfig{}, &models.Setting{})) + + service := NewEnhancedSecurityNotificationService(db) + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + err = service.updateManagedProviders(&models.NotificationConfig{DiscordWebhookURL: "https://discord.com/api/webhooks/123/abc"}) + require.Error(t, err) +} + +func TestEnhancedService_UpdateManagedProviders_ChangesACLTypeAndToken(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.NotificationConfig{}, &models.Setting{})) + + provider := models.NotificationProvider{ + ID: "managed-change", + Type: "webhook", + URL: "https://example.com/webhook", + Token: "old-token", + Enabled: true, + ManagedLegacySecurity: true, + NotifySecurityWAFBlocks: true, + NotifySecurityACLDenies: false, + NotifySecurityRateLimitHits: false, + } + require.NoError(t, db.Create(&provider).Error) + + service := NewEnhancedSecurityNotificationService(db) + err = service.updateManagedProviders(&models.NotificationConfig{ + NotifyWAFBlocks: true, + NotifyACLDenies: true, + NotifyRateLimitHits: false, + GotifyURL: "https://gotify.example.com", + GotifyToken: "new-token", + }) + require.NoError(t, err) + + var updated models.NotificationProvider + require.NoError(t, db.First(&updated, "id = ?", "managed-change").Error) + assert.True(t, updated.NotifySecurityACLDenies) + assert.Equal(t, "gotify", updated.Type) + assert.Equal(t, "new-token", updated.Token) +} + +func TestEnhancedService_UpdateManagedProviders_SaveError(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "enhanced-save-error.db") + + rwDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, rwDB.AutoMigrate(&models.NotificationProvider{}, &models.NotificationConfig{}, &models.Setting{})) + require.NoError(t, rwDB.Create(&models.NotificationProvider{ + ID: "managed-readonly", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", + Enabled: true, + ManagedLegacySecurity: true, + NotifySecurityWAFBlocks: false, + }).Error) + + rwSQL, err := rwDB.DB() + require.NoError(t, err) + require.NoError(t, rwSQL.Close()) + + roDB, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=ro", dbPath)), &gorm.Config{}) + require.NoError(t, err) + service := NewEnhancedSecurityNotificationService(roDB) + + err = service.updateManagedProviders(&models.NotificationConfig{ + NotifyWAFBlocks: true, + DiscordWebhookURL: "https://discord.com/api/webhooks/123/abc", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "update provider") +} + +func TestEnhancedService_UpdateLegacyConfig_DBErrorAndUpdatePath(t *testing.T) { + t.Run("db_error", func(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationConfig{})) + service := NewEnhancedSecurityNotificationService(db) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + err = service.updateLegacyConfig(&models.NotificationConfig{NotifyWAFBlocks: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "fetch existing config") + }) + + t.Run("update_existing_preserves_id", func(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationConfig{})) + service := NewEnhancedSecurityNotificationService(db) + + existing := models.NotificationConfig{ID: "legacy-id", NotifyWAFBlocks: false} + require.NoError(t, db.Create(&existing).Error) + + req := &models.NotificationConfig{NotifyWAFBlocks: true} + require.NoError(t, service.updateLegacyConfig(req)) + assert.Equal(t, "legacy-id", req.ID) + }) +} + +func TestEnhancedService_MigrateFromLegacyConfig_PreTransactionErrors(t *testing.T) { + t.Run("feature_flag_error", func(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + service := NewEnhancedSecurityNotificationService(db) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + err = service.MigrateFromLegacyConfig() + require.Error(t, err) + assert.Contains(t, err.Error(), "check feature flag") + }) + + t.Run("read_legacy_config_error", func(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.Setting{})) + require.NoError(t, db.Create(&models.Setting{Key: "feature.notifications.security_provider_events.enabled", Value: "true", Type: "bool"}).Error) + + service := NewEnhancedSecurityNotificationService(db) + err = service.MigrateFromLegacyConfig() + require.Error(t, err) + assert.Contains(t, err.Error(), "read legacy config") + }) +} + +func TestEnhancedService_MigrateFromLegacyConfig_InvalidMarkerJSONContinues(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.NotificationConfig{}, &models.Setting{})) + require.NoError(t, db.Create(&models.Setting{Key: "feature.notifications.security_provider_events.enabled", Value: "true", Type: "bool"}).Error) + require.NoError(t, db.Create(&models.NotificationConfig{ID: "legacy", NotifyWAFBlocks: true, WebhookURL: "https://example.com/webhook"}).Error) + require.NoError(t, db.Create(&models.Setting{Key: "notifications.security_provider_events.migration.v1", Value: "{invalid-json", Type: "json", Category: "notifications"}).Error) + + service := NewEnhancedSecurityNotificationService(db) + require.NoError(t, service.MigrateFromLegacyConfig()) + + var count int64 + require.NoError(t, db.Model(&models.NotificationProvider{}).Where("managed_legacy_security = ?", true).Count(&count).Error) + assert.Equal(t, int64(1), count) +} + +func TestEnhancedService_MigrateFromLegacyConfig_TransactionWriteErrors(t *testing.T) { + t.Run("create_managed_provider_error", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "enhanced-migrate-create-error.db") + rwDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, rwDB.AutoMigrate(&models.NotificationProvider{}, &models.NotificationConfig{}, &models.Setting{})) + require.NoError(t, rwDB.Create(&models.Setting{Key: "feature.notifications.security_provider_events.enabled", Value: "true", Type: "bool"}).Error) + require.NoError(t, rwDB.Create(&models.NotificationConfig{ID: "legacy", NotifyWAFBlocks: true, WebhookURL: "https://example.com/webhook"}).Error) + + rwSQL, err := rwDB.DB() + require.NoError(t, err) + require.NoError(t, rwSQL.Close()) + + roDB, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=ro", dbPath)), &gorm.Config{}) + require.NoError(t, err) + service := NewEnhancedSecurityNotificationService(roDB) + + err = service.MigrateFromLegacyConfig() + require.Error(t, err) + assert.Contains(t, err.Error(), "create managed provider") + }) + + t.Run("update_managed_provider_error", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "enhanced-migrate-update-error.db") + rwDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, rwDB.AutoMigrate(&models.NotificationProvider{}, &models.NotificationConfig{}, &models.Setting{})) + require.NoError(t, rwDB.Create(&models.Setting{Key: "feature.notifications.security_provider_events.enabled", Value: "true", Type: "bool"}).Error) + require.NoError(t, rwDB.Create(&models.NotificationConfig{ID: "legacy", NotifyWAFBlocks: true, WebhookURL: "https://example.com/webhook"}).Error) + require.NoError(t, rwDB.Create(&models.NotificationProvider{ID: "managed", Type: "webhook", URL: "https://old.example.com", Enabled: true, ManagedLegacySecurity: true}).Error) + + rwSQL, err := rwDB.DB() + require.NoError(t, err) + require.NoError(t, rwSQL.Close()) + + roDB, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=ro", dbPath)), &gorm.Config{}) + require.NoError(t, err) + service := NewEnhancedSecurityNotificationService(roDB) + + err = service.MigrateFromLegacyConfig() + require.Error(t, err) + assert.Contains(t, err.Error(), "update managed provider") + }) +} + +func TestEnhancedService_IsFeatureEnabled_CreateAndRequeryPath(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "feature-flag-requery.db") + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.Setting{})) + + raceDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + + injected := false + callbackName := "test_inject_feature_flag_before_create" + _ = db.Callback().Create().Before("gorm:create").Register(callbackName, func(tx *gorm.DB) { + if tx.Statement.Schema == nil || tx.Statement.Schema.Table != "settings" || injected { + return + } + injected = true + _ = raceDB.Exec("INSERT OR IGNORE INTO settings (key, value, type, category, updated_at) VALUES (?, ?, ?, ?, ?)", + "feature.notifications.security_provider_events.enabled", + "true", + "bool", + "feature", + time.Now(), + ).Error + }) + defer func() { + _ = db.Callback().Create().Remove(callbackName) + }() + + service := NewEnhancedSecurityNotificationService(db) + enabled, err := service.isFeatureEnabled() + require.NoError(t, err) + assert.True(t, enabled) + + raceSQL, sqlErr := raceDB.DB() + if sqlErr == nil { + _ = raceSQL.Close() + } +} + +func TestEnhancedService_SendViaProviders_QueryProvidersErrorAndCrowdSecRouting(t *testing.T) { + t.Run("query_providers_error", func(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) + service := NewEnhancedSecurityNotificationService(db) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + err = service.SendViaProviders(context.Background(), models.SecurityEvent{EventType: "waf_block"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "query providers") + }) + + t.Run("crowdsec_decision_routes_to_subscribed_provider", func(t *testing.T) { + serverCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + serverCalls++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Setting{})) + require.NoError(t, db.Create(&models.NotificationProvider{ + ID: "discord-crowdsec-route", + Type: "discord", + URL: server.URL, + Enabled: true, + NotifySecurityCrowdSecDecisions: true, + }).Error) + + service := NewEnhancedSecurityNotificationService(db) + err = service.SendViaProviders(context.Background(), models.SecurityEvent{ + EventType: "crowdsec_decision", + Severity: "warn", + Message: "CrowdSec decision", + Timestamp: time.Now(), + }) + require.NoError(t, err) + assert.Equal(t, 1, serverCalls) + }) +} + +func TestEnhancedService_SendWebhook_MarshalAndExecuteErrorPaths(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + service := NewEnhancedSecurityNotificationService(db) + + t.Run("marshal_error", func(t *testing.T) { + err := service.sendWebhook(context.Background(), "http://127.0.0.1:8080/webhook", models.SecurityEvent{ + EventType: "waf_block", + Metadata: map[string]any{ + "bad": make(chan int), + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "marshal event") + }) + + t.Run("execute_request_error", func(t *testing.T) { + err := service.sendWebhook(context.Background(), "http://127.0.0.1:1/webhook", models.SecurityEvent{ + EventType: "waf_block", + Severity: "warn", + Message: "connect failure expected", + Timestamp: time.Now(), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "execute request") + }) +} + +func TestEnhancedService_UpdateManagedProviders_WrapsManagedQueryError(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationConfig{}, &models.Setting{})) + // notification_providers table intentionally absent + + service := NewEnhancedSecurityNotificationService(db) + err = service.updateManagedProviders(&models.NotificationConfig{DiscordWebhookURL: "https://discord.com/api/webhooks/123/abc"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "query managed providers") +} + +func TestEnhancedService_MigrateFromLegacyConfig_WrapsManagedProviderQueryError(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationConfig{}, &models.Setting{})) + require.NoError(t, db.Create(&models.Setting{Key: "feature.notifications.security_provider_events.enabled", Value: "true", Type: "bool"}).Error) + require.NoError(t, db.Create(&models.NotificationConfig{ID: "legacy", NotifyWAFBlocks: true, WebhookURL: "https://example.com/webhook"}).Error) + // notification_providers table intentionally absent + + service := NewEnhancedSecurityNotificationService(db) + err = service.MigrateFromLegacyConfig() + require.Error(t, err) + assert.Contains(t, err.Error(), "query managed provider") +} + +func TestEnhancedService_IsFeatureEnabled_CreateAndRequeryErrorPath(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "feature-flag-requery-error.db") + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.Setting{})) + + readonlyDB, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=ro", dbPath)), &gorm.Config{}) + require.NoError(t, err) + readonlyService := NewEnhancedSecurityNotificationService(readonlyDB) + + _, err = readonlyService.isFeatureEnabled() + require.Error(t, err) + assert.Contains(t, err.Error(), "create and requery feature flag") + + sqlDB, sqlErr := db.DB() + if sqlErr == nil { + _ = sqlDB.Close() + } +} + +func TestEnhancedService_SendViaProviders_RateLimitRoutingBranch(t *testing.T) { + serverCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + serverCalls++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Setting{})) + require.NoError(t, db.Create(&models.NotificationProvider{ + ID: "discord-rate-limit-route", + Type: "discord", + URL: server.URL, + Enabled: true, + NotifySecurityRateLimitHits: true, + }).Error) + + service := NewEnhancedSecurityNotificationService(db) + err = service.SendViaProviders(context.Background(), models.SecurityEvent{ + EventType: "rate limit hit", + Severity: "warn", + Message: "Rate limit triggered", + Timestamp: time.Now(), + }) + require.NoError(t, err) + assert.Equal(t, 1, serverCalls) +} diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index 45892092..84576104 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -8,6 +8,8 @@ import ( "net" "net/http" "net/http/httptest" + "os" + "path/filepath" "sync/atomic" "testing" "time" @@ -1802,6 +1804,160 @@ func TestLegacyFallbackInvocationError(t *testing.T) { assert.Contains(t, err.Error(), "only discord provider type is supported") } +func TestLegacyFallbackInvocationError_DirectHelperAndHook(t *testing.T) { + err := legacyFallbackInvocationError("telegram") + require.Error(t, err) + assert.Contains(t, err.Error(), "legacy fallback is retired and disabled") + assert.Contains(t, err.Error(), "provider type \"telegram\"") + + hookErr := legacySendFunc("ignored", "ignored") + require.Error(t, hookErr) + assert.ErrorIs(t, hookErr, ErrLegacyFallbackDisabled) +} + +func TestNotificationService_SendExternal_SecurityEventRouting(t *testing.T) { + eventCases := []struct { + name string + eventType string + apply func(p *models.NotificationProvider) + }{ + { + name: "security_waf", + eventType: "security_waf", + apply: func(p *models.NotificationProvider) { + p.NotifySecurityWAFBlocks = true + }, + }, + { + name: "security_acl", + eventType: "security_acl", + apply: func(p *models.NotificationProvider) { + p.NotifySecurityACLDenies = true + }, + }, + { + name: "security_rate_limit", + eventType: "security_rate_limit", + apply: func(p *models.NotificationProvider) { + p.NotifySecurityRateLimitHits = true + }, + }, + { + name: "security_crowdsec", + eventType: "security_crowdsec", + apply: func(p *models.NotificationProvider) { + p.NotifySecurityCrowdSecDecisions = true + }, + }, + } + + for _, tc := range eventCases { + t.Run(tc.name, func(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db) + + origValidate := validateDiscordProviderURLFunc + defer func() { validateDiscordProviderURLFunc = origValidate }() + validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } + + received := make(chan struct{}, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received <- struct{}{} + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + provider := models.NotificationProvider{ + Name: "discord-security", + Type: "discord", + URL: server.URL, + Enabled: true, + Template: "minimal", + } + tc.apply(&provider) + require.NoError(t, db.Create(&provider).Error) + + svc.SendExternal(context.Background(), tc.eventType, "Security Title", "Security Message", nil) + + select { + case <-received: + case <-time.After(1 * time.Second): + t.Fatalf("expected dispatch for event type %s", tc.eventType) + } + }) + } +} + +func TestNotificationService_UpdateProvider_ReturnsErrorWhenProviderMissing(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db) + + err := svc.UpdateProvider(&models.NotificationProvider{ + ID: "missing-id", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/token", + }) + require.Error(t, err) +} + +func TestNotificationService_EnsureNotifyOnlyProviderMigration_QueryProvidersError(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + err = svc.EnsureNotifyOnlyProviderMigration(context.Background()) + require.Error(t, err) +} + +func TestNotificationService_EnsureNotifyOnlyProviderMigration_UpdateError(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "migration_update_error.db") + + rwDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, rwDB.AutoMigrate(&models.NotificationProvider{})) + require.NoError(t, rwDB.Create(&models.NotificationProvider{ + ID: "provider-to-update", + Name: "Legacy Webhook", + Type: "webhook", + URL: "https://example.com/webhook", + Enabled: true, + MigrationState: "pending", + }).Error) + + rwSQLDB, err := rwDB.DB() + require.NoError(t, err) + require.NoError(t, rwSQLDB.Close()) + + roDSN := fmt.Sprintf("file:%s?mode=ro", dbPath) + roDB, err := gorm.Open(sqlite.Open(roDSN), &gorm.Config{}) + require.NoError(t, err) + + svc := NewNotificationService(roDB) + err = svc.EnsureNotifyOnlyProviderMigration(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to migrate notification provider") + + roSQLDB, sqlErr := roDB.DB() + if sqlErr == nil { + _ = roSQLDB.Close() + } + _ = os.Remove(dbPath) +} + +func TestNotificationService_EnsureNotifyOnlyProviderMigration_WrapsFindError(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + // Intentionally do not migrate notification_providers table. + + svc := NewNotificationService(db) + err = svc.EnsureNotifyOnlyProviderMigration(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch notification providers for migration") +} + func TestTestProvider_NotifyOnlyRejectsUnsupportedProvider(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) diff --git a/backend/internal/services/proxyhost_service_validation_test.go b/backend/internal/services/proxyhost_service_validation_test.go index f605f62e..c39fa826 100644 --- a/backend/internal/services/proxyhost_service_validation_test.go +++ b/backend/internal/services/proxyhost_service_validation_test.go @@ -342,3 +342,27 @@ func TestProxyHostService_ValidateProxyHost_DNSChallenge(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "dns provider is required") } + +func TestProxyHostService_ValidateHostname_StripsPath(t *testing.T) { + db := setupProxyHostTestDB(t) + service := NewProxyHostService(db) + + err := service.ValidateHostname("backend.internal/api/v1") + assert.NoError(t, err) +} + +func TestProxyHostService_ValidateProxyHost_ParseFallbackAndPathTrim(t *testing.T) { + db := setupProxyHostTestDB(t) + service := NewProxyHostService(db) + + host := &models.ProxyHost{ + UUID: uuid.New().String(), + DomainNames: "fallback-path.example.com", + ForwardHost: "https://bad host/path", + ForwardPort: 8080, + } + + err := service.Create(host) + assert.Error(t, err) + assert.Contains(t, err.Error(), "forward host must be a valid IP address or hostname") +}