Files
Charon/backend/internal/api/handlers/security_notifications_compatibility_test.go
2026-03-04 18:34:49 +00:00

576 lines
19 KiB
Go

package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"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"
)
// SetupCompatibilityTestDB creates an in-memory database for testing (exported for use by other test files).
func SetupCompatibilityTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&models.NotificationProvider{},
&models.NotificationConfig{},
&models.Setting{},
))
// Enable feature flag by default in tests
featureFlag := &models.Setting{
Key: "feature.notifications.security_provider_events.enabled",
Value: "true",
Type: "bool",
Category: "feature",
}
require.NoError(t, db.Create(featureFlag).Error)
return db
}
// TestCompatibilityGET_ORAggregation tests that GET uses OR semantics for aggregation.
func TestCompatibilityGET_ORAggregation(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Create two providers with different security event settings
provider1 := &models.NotificationProvider{
Name: "Provider 1",
Type: "webhook",
Enabled: true,
NotifySecurityWAFBlocks: true,
NotifySecurityACLDenies: false,
NotifySecurityRateLimitHits: false,
}
provider2 := &models.NotificationProvider{
Name: "Provider 2",
Type: "discord",
Enabled: true,
NotifySecurityWAFBlocks: false,
NotifySecurityACLDenies: true,
NotifySecurityRateLimitHits: true,
}
require.NoError(t, db.Create(provider1).Error)
require.NoError(t, db.Create(provider2).Error)
// Create handler with enhanced service
service := services.NewEnhancedSecurityNotificationService(db)
handler := NewSecurityNotificationHandler(service)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/notifications/settings/security", http.NoBody)
handler.GetSettings(c)
assert.Equal(t, http.StatusOK, w.Code)
var response models.NotificationConfig
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// OR semantics: if ANY provider has true, result is true
assert.True(t, response.NotifyWAFBlocks, "WAF should be enabled (provider1=true)")
assert.True(t, response.NotifyACLDenies, "ACL should be enabled (provider2=true)")
assert.True(t, response.NotifyRateLimitHits, "Rate limit should be enabled (provider2=true)")
}
// TestCompatibilityGET_AllFalse tests that GET returns false when all providers are false.
func TestCompatibilityGET_AllFalse(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Create provider with all false
provider := &models.NotificationProvider{
Name: "Provider 1",
Type: "webhook",
Enabled: true,
NotifySecurityWAFBlocks: false,
NotifySecurityACLDenies: false,
NotifySecurityRateLimitHits: false,
}
require.NoError(t, db.Create(provider).Error)
service := services.NewEnhancedSecurityNotificationService(db)
handler := NewSecurityNotificationHandler(service)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/notifications/settings/security", http.NoBody)
handler.GetSettings(c)
assert.Equal(t, http.StatusOK, w.Code)
var response models.NotificationConfig
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.False(t, response.NotifyWAFBlocks)
assert.False(t, response.NotifyACLDenies)
assert.False(t, response.NotifyRateLimitHits)
}
// TestCompatibilityGET_DisabledProvidersIgnored tests that disabled providers are not included in aggregation.
func TestCompatibilityGET_DisabledProvidersIgnored(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Create enabled and disabled providers
enabled := &models.NotificationProvider{
Name: "Enabled",
Type: "webhook",
Enabled: true,
NotifySecurityWAFBlocks: false,
}
disabled := &models.NotificationProvider{
Name: "Disabled",
Type: "discord",
Enabled: false,
NotifySecurityWAFBlocks: true, // Should be ignored
}
require.NoError(t, db.Create(enabled).Error)
require.NoError(t, db.Create(disabled).Error)
service := services.NewEnhancedSecurityNotificationService(db)
handler := NewSecurityNotificationHandler(service)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/notifications/settings/security", http.NoBody)
handler.GetSettings(c)
var response models.NotificationConfig
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Disabled provider should not affect result
assert.False(t, response.NotifyWAFBlocks, "Disabled provider should not contribute to OR")
}
// TestCompatibilityPUT_DeterministicTargetSet tests that PUT returns 410 Gone per R6 contract.
func TestCompatibilityPUT_DeterministicTargetSet(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Create one managed provider
managed := &models.NotificationProvider{
Name: "Migrated Security Notifications (Legacy)",
Type: "webhook",
Enabled: true,
ManagedLegacySecurity: true,
}
require.NoError(t, db.Create(managed).Error)
service := services.NewEnhancedSecurityNotificationService(db)
handler := NewSecurityNotificationHandler(service)
body := []byte(`{
"security_waf_enabled": true,
"security_acl_enabled": false,
"security_rate_limit_enabled": true
}`)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("PUT", "/api/v1/notifications/settings/security", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c)
// R6 contract: PUT returns 410 Gone
assert.Equal(t, http.StatusGone, w.Code)
// Verify no mutations occurred (provider unchanged)
var updated models.NotificationProvider
require.NoError(t, db.First(&updated, "id = ?", managed.ID).Error)
assert.False(t, updated.NotifySecurityWAFBlocks, "Provider should not be mutated by deprecated endpoint")
assert.False(t, updated.NotifySecurityACLDenies)
assert.False(t, updated.NotifySecurityRateLimitHits)
}
// TestCompatibilityPUT_CreatesManagedProviderIfNone tests that PUT returns 410 Gone per R6 contract.
func TestCompatibilityPUT_CreatesManagedProviderIfNone(t *testing.T) {
db := SetupCompatibilityTestDB(t)
service := services.NewEnhancedSecurityNotificationService(db)
handler := NewSecurityNotificationHandler(service)
body := []byte(`{
"security_waf_enabled": true,
"security_acl_enabled": true,
"security_rate_limit_enabled": false,
"webhook_url": "https://example.com/webhook"
}`)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("PUT", "/api/v1/notifications/settings/security", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c)
// R6 contract: PUT returns 410 Gone
assert.Equal(t, http.StatusGone, w.Code)
// Verify no provider was created
var count int64
db.Model(&models.NotificationProvider{}).Where("managed_legacy_security = ?", true).Count(&count)
assert.Equal(t, int64(0), count, "No provider should be created by deprecated endpoint")
}
// TestCompatibilityPUT_Idempotency tests that PUT returns 410 Gone per R6 contract.
func TestCompatibilityPUT_Idempotency(t *testing.T) {
db := SetupCompatibilityTestDB(t)
managed := &models.NotificationProvider{
Name: "Migrated Security Notifications (Legacy)",
Type: "webhook",
Enabled: true,
ManagedLegacySecurity: true,
NotifySecurityWAFBlocks: true,
NotifySecurityACLDenies: false,
NotifySecurityRateLimitHits: true,
}
require.NoError(t, db.Create(managed).Error)
service := services.NewEnhancedSecurityNotificationService(db)
handler := NewSecurityNotificationHandler(service)
body := []byte(`{
"security_waf_enabled": true,
"security_acl_enabled": false,
"security_rate_limit_enabled": true
}`)
// First PUT
gin.SetMode(gin.TestMode)
w1 := httptest.NewRecorder()
c1, _ := gin.CreateTestContext(w1)
setAdminContext(c1)
c1.Request = httptest.NewRequest("PUT", "/api/v1/notifications/settings/security", bytes.NewBuffer(body))
c1.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c1)
assert.Equal(t, http.StatusGone, w1.Code, "R6 contract: PUT returns 410 Gone")
var afterFirst models.NotificationProvider
require.NoError(t, db.First(&afterFirst, "id = ?", managed.ID).Error)
// Second PUT with identical payload
w2 := httptest.NewRecorder()
c2, _ := gin.CreateTestContext(w2)
setAdminContext(c2)
c2.Request = httptest.NewRequest("PUT", "/api/v1/notifications/settings/security", bytes.NewBuffer(body))
c2.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c2)
assert.Equal(t, http.StatusGone, w2.Code, "R6 contract: PUT returns 410 Gone")
var afterSecond models.NotificationProvider
require.NoError(t, db.First(&afterSecond, "id = ?", managed.ID).Error)
// Values should remain identical (no mutations)
assert.Equal(t, afterFirst.NotifySecurityWAFBlocks, afterSecond.NotifySecurityWAFBlocks)
assert.Equal(t, afterFirst.NotifySecurityACLDenies, afterSecond.NotifySecurityACLDenies)
assert.Equal(t, afterFirst.NotifySecurityRateLimitHits, afterSecond.NotifySecurityRateLimitHits)
// Original values should be preserved
assert.True(t, afterSecond.NotifySecurityWAFBlocks, "Original values preserved")
assert.False(t, afterSecond.NotifySecurityACLDenies)
assert.True(t, afterSecond.NotifySecurityRateLimitHits)
}
// TestCompatibilityPUT_WebhookMapping tests that PUT returns 410 Gone per R6 contract.
func TestCompatibilityPUT_WebhookMapping(t *testing.T) {
db := SetupCompatibilityTestDB(t)
service := services.NewEnhancedSecurityNotificationService(db)
handler := NewSecurityNotificationHandler(service)
body := []byte(`{
"security_waf_enabled": true,
"webhook_url": "https://example.com/webhook"
}`)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("PUT", "/api/v1/notifications/settings/security", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c)
// R6 contract: PUT returns 410 Gone
assert.Equal(t, http.StatusGone, w.Code)
// Verify no provider was created
var count int64
db.Model(&models.NotificationProvider{}).Where("managed_legacy_security = ?", true).Count(&count)
assert.Equal(t, int64(0), count, "No provider should be created by deprecated endpoint")
}
// TestCompatibilityPUT_MultipleDestinations422 tests that PUT returns 410 Gone per R6 contract.
func TestCompatibilityPUT_MultipleDestinations422(t *testing.T) {
db := SetupCompatibilityTestDB(t)
service := services.NewEnhancedSecurityNotificationService(db)
handler := NewSecurityNotificationHandler(service)
body := []byte(`{
"security_waf_enabled": true,
"webhook_url": "https://example.com/webhook",
"discord_webhook_url": "https://discord.com/webhook"
}`)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("PUT", "/api/v1/notifications/settings/security", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c)
// R6 contract: PUT returns 410 Gone regardless of payload
assert.Equal(t, http.StatusGone, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response["error"], "deprecated")
}
// TestMigrationMarker_Deterministic tests migration marker checksum and rerun logic.
func TestMigrationMarker_Deterministic(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Create legacy config
legacyConfig := &models.NotificationConfig{
NotifyWAFBlocks: true,
NotifyACLDenies: false,
NotifyRateLimitHits: true,
WebhookURL: "https://example.com/webhook",
}
require.NoError(t, db.Create(legacyConfig).Error)
service := services.NewEnhancedSecurityNotificationService(db)
// First migration
err := service.MigrateFromLegacyConfig()
require.NoError(t, err)
// Verify migration marker was created
var marker models.Setting
err = db.Where("key = ?", "notifications.security_provider_events.migration.v1").First(&marker).Error
require.NoError(t, err)
var markerData MigrationMarker
err = json.Unmarshal([]byte(marker.Value), &markerData)
require.NoError(t, err)
assert.Equal(t, "v1", markerData.Version)
assert.NotEmpty(t, markerData.Checksum)
assert.Equal(t, "completed", markerData.Result)
// Verify provider was created
var provider models.NotificationProvider
err = db.Where("managed_legacy_security = ?", true).First(&provider).Error
require.NoError(t, err)
assert.True(t, provider.NotifySecurityWAFBlocks)
assert.False(t, provider.NotifySecurityACLDenies)
assert.True(t, provider.NotifySecurityRateLimitHits)
// Second migration with same checksum should be no-op
err = service.MigrateFromLegacyConfig()
require.NoError(t, err)
// Count providers - should still be 1
var count int64
db.Model(&models.NotificationProvider{}).Where("managed_legacy_security = ?", true).Count(&count)
assert.Equal(t, int64(1), count)
}
// TestCompatibilityPUT_MultipleManagedProviders_UpdatesAll tests that PUT returns 410 Gone per R6 contract.
func TestCompatibilityPUT_MultipleManagedProviders_UpdatesAll(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Create two managed providers (valid scenario after migration)
managed1 := &models.NotificationProvider{
Name: "Migrated Security Notifications (Legacy)",
Type: "webhook",
Enabled: true,
ManagedLegacySecurity: true,
}
managed2 := &models.NotificationProvider{
Name: "Duplicate Managed Provider",
Type: "webhook",
Enabled: true,
ManagedLegacySecurity: true,
}
require.NoError(t, db.Create(managed1).Error)
require.NoError(t, db.Create(managed2).Error)
service := services.NewEnhancedSecurityNotificationService(db)
handler := NewSecurityNotificationHandler(service)
body := []byte(`{
"security_waf_enabled": true,
"security_acl_enabled": false,
"security_rate_limit_enabled": true
}`)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("PUT", "/api/v1/notifications/settings/security", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c)
// R6 contract: PUT returns 410 Gone
assert.Equal(t, http.StatusGone, w.Code, "PUT must return 410 Gone per R6 deprecation contract")
// Verify no providers were mutated
var updated1, updated2 models.NotificationProvider
require.NoError(t, db.First(&updated1, "id = ?", managed1.ID).Error)
require.NoError(t, db.First(&updated2, "id = ?", managed2.ID).Error)
assert.False(t, updated1.NotifySecurityWAFBlocks, "Provider should not be mutated by deprecated endpoint")
assert.False(t, updated1.NotifySecurityACLDenies)
assert.False(t, updated1.NotifySecurityRateLimitHits)
assert.False(t, updated2.NotifySecurityWAFBlocks, "Provider should not be mutated by deprecated endpoint")
assert.False(t, updated2.NotifySecurityACLDenies)
assert.False(t, updated2.NotifySecurityRateLimitHits)
}
// TestFeatureFlagDefaultInitialization tests feature flag auto-initialization with correct defaults.
func TestFeatureFlagDefaultInitialization(t *testing.T) {
tests := []struct {
name string
ginMode string
expectedValue bool
setupTestMarker bool
}{
{
name: "Production (no GIN_MODE)",
ginMode: "",
expectedValue: false,
},
{
name: "Development (GIN_MODE=debug)",
ginMode: "debug",
expectedValue: true,
},
{
name: "Test (GIN_MODE=test)",
ginMode: "test",
expectedValue: true,
},
{
name: "Test marker present",
ginMode: "",
expectedValue: true,
setupTestMarker: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Clear the auto-created feature flag
db.Where("key = ?", "feature.notifications.security_provider_events.enabled").Delete(&models.Setting{})
if tt.setupTestMarker {
testMarker := &models.Setting{
Key: "_test_mode_marker",
Value: "true",
Type: "bool",
Category: "internal",
}
require.NoError(t, db.Create(testMarker).Error)
}
// Set GIN_MODE if specified
if tt.ginMode != "" {
_ = os.Setenv("GIN_MODE", tt.ginMode)
defer func() { _ = os.Unsetenv("GIN_MODE") }()
}
service := services.NewEnhancedSecurityNotificationService(db)
// Call method that checks feature flag (should auto-initialize)
_, err := service.GetSettings()
require.NoError(t, err)
// Verify flag was created with correct default
var setting models.Setting
err = db.Where("key = ?", "feature.notifications.security_provider_events.enabled").First(&setting).Error
require.NoError(t, err)
expectedValueStr := "false"
if tt.expectedValue {
expectedValueStr = "true"
}
assert.Equal(t, expectedValueStr, setting.Value, "Feature flag should have correct default for %s", tt.name)
})
}
}
// TestFeatureFlag_Disabled tests behavior when feature flag is disabled.
func TestFeatureFlag_Disabled(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Update feature flag to false (SetupCompatibilityTestDB already created it as true)
result := db.Model(&models.Setting{}).
Where("key = ?", "feature.notifications.security_provider_events.enabled").
Update("value", "false")
require.NoError(t, result.Error)
require.Equal(t, int64(1), result.RowsAffected)
service := services.NewEnhancedSecurityNotificationService(db)
handler := NewSecurityNotificationHandler(service)
// GET should still work via compatibility path
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/notifications/settings/security", http.NoBody)
handler.GetSettings(c)
assert.Equal(t, http.StatusOK, w.Code)
}
// CompatibilitySecuritySettings represents the compatibility GET response structure.
type CompatibilitySecuritySettings struct {
SecurityWAFEnabled bool `json:"security_waf_enabled"`
SecurityACLEnabled bool `json:"security_acl_enabled"`
SecurityRateLimitEnabled bool `json:"security_rate_limit_enabled"`
Destination string `json:"destination,omitempty"`
DestinationAmbiguous bool `json:"destination_ambiguous,omitempty"`
}
// MigrationMarker represents the migration marker stored in settings.
type MigrationMarker struct {
Version string `json:"version"`
Checksum string `json:"checksum"`
LastCompletedAt string `json:"last_completed_at"`
Result string `json:"result"`
}