Files
Charon/backend/internal/api/handlers/security_notifications_final_blockers_test.go
GitHub Actions 65d02e754e feat: add support for Pushover notification provider
- Updated the list of supported notification provider types to include 'pushover'.
- Enhanced the notifications API tests to validate Pushover integration.
- Modified the notifications form to include fields specific to Pushover, such as API Token and User Key.
- Implemented CRUD operations for Pushover providers in the settings.
- Added end-to-end tests for Pushover provider functionality, including form rendering, payload validation, and security checks.
- Updated translations to include Pushover-specific labels and placeholders.
2026-03-16 18:16:14 +00:00

509 lines
19 KiB
Go

package handlers
import (
"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"
)
// TestFinalBlocker1_DestinationAmbiguous_ZeroManagedProviders tests that destination_ambiguous=true when no managed providers exist.
func TestFinalBlocker1_DestinationAmbiguous_ZeroManagedProviders(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Create a non-managed provider
provider := &models.NotificationProvider{
Name: "Regular Provider",
Type: "webhook",
Enabled: true,
ManagedLegacySecurity: false,
NotifySecurityWAFBlocks: true,
}
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)
// Zero managed providers should result in ambiguous=true
assert.True(t, response.DestinationAmbiguous, "destination_ambiguous should be true when zero managed providers exist")
assert.Empty(t, response.WebhookURL, "No destination should be reported")
}
// TestFinalBlocker1_DestinationAmbiguous_OneManagedProvider tests that destination_ambiguous=false when exactly one managed provider exists.
func TestFinalBlocker1_DestinationAmbiguous_OneManagedProvider(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Create one managed provider
provider := &models.NotificationProvider{
Name: "Managed Provider",
Type: "webhook",
URL: "https://example.com/webhook",
Enabled: true,
ManagedLegacySecurity: true,
NotifySecurityWAFBlocks: true,
}
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)
// Exactly one managed provider should result in ambiguous=false
assert.False(t, response.DestinationAmbiguous, "destination_ambiguous should be false when exactly one managed provider exists")
assert.Equal(t, "https://example.com/webhook", response.WebhookURL, "Destination should be reported")
}
// TestFinalBlocker1_DestinationAmbiguous_MultipleManagedProviders tests that destination_ambiguous=true when multiple managed providers exist.
func TestFinalBlocker1_DestinationAmbiguous_MultipleManagedProviders(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Create two managed providers
provider1 := &models.NotificationProvider{
Name: "Managed Provider 1",
Type: "webhook",
URL: "https://example.com/webhook1",
Enabled: true,
ManagedLegacySecurity: true,
NotifySecurityWAFBlocks: true,
}
provider2 := &models.NotificationProvider{
Name: "Managed Provider 2",
Type: "discord",
URL: "https://discord.com/webhook",
Enabled: true,
ManagedLegacySecurity: true,
NotifySecurityACLDenies: true,
}
require.NoError(t, db.Create(provider1).Error)
require.NoError(t, db.Create(provider2).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)
// Multiple managed providers should result in ambiguous=true
assert.True(t, response.DestinationAmbiguous, "destination_ambiguous should be true when multiple managed providers exist")
assert.Empty(t, response.WebhookURL, "No single destination should be reported")
assert.Empty(t, response.DiscordWebhookURL, "No single destination should be reported")
}
// TestFinalBlocker2_TokenNotExposed tests that provider tokens are not exposed in API responses.
func TestFinalBlocker2_TokenNotExposed(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Create a gotify provider with token
provider := &models.NotificationProvider{
Name: "Gotify Provider",
Type: "gotify",
URL: "https://gotify.example.com",
Token: "secret_gotify_token_12345",
Enabled: true,
NotifySecurityWAFBlocks: true,
}
require.NoError(t, db.Create(provider).Error)
// Fetch provider via API simulation
var fetchedProvider models.NotificationProvider
err := db.First(&fetchedProvider, "id = ?", provider.ID).Error
require.NoError(t, err)
// Serialize to JSON (simulating API response)
jsonBytes, err := json.Marshal(fetchedProvider)
require.NoError(t, err)
// Parse back to map to check field presence
var response map[string]interface{}
err = json.Unmarshal(jsonBytes, &response)
require.NoError(t, err)
// Token field should NOT be present in JSON
_, tokenExists := response["token"]
assert.False(t, tokenExists, "Token field should not be present in JSON response (json:\"-\" tag)")
// But Token should still be accessible in Go code
assert.Equal(t, "secret_gotify_token_12345", fetchedProvider.Token, "Token should still be accessible in Go code")
}
// TestFinalBlocker3_SupportedProviderTypes_WebhookDiscordSlackGotifyOnly tests that only supported types are processed.
func TestFinalBlocker3_SupportedProviderTypes_WebhookDiscordSlackGotifyOnly(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Create providers of various types
supportedTypes := []string{"webhook", "discord", "slack", "gotify"}
unsupportedTypes := []string{"telegram", "generic", "unknown"}
// Create supported providers
for _, providerType := range supportedTypes {
provider := &models.NotificationProvider{
Name: providerType + " provider",
Type: providerType,
Enabled: true,
NotifySecurityWAFBlocks: true,
}
require.NoError(t, db.Create(provider).Error)
}
// Create unsupported providers
for _, providerType := range unsupportedTypes {
provider := &models.NotificationProvider{
Name: providerType + " provider",
Type: providerType,
Enabled: true,
NotifySecurityWAFBlocks: true,
}
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)
// WAF should be enabled because supported types have it enabled
// Unsupported types should be filtered out and not affect the aggregation
assert.True(t, response.NotifyWAFBlocks, "WAF should be enabled (supported providers have it enabled)")
}
// TestFinalBlocker3_SupportedProviderTypes_UnsupportedTypesIgnored tests that unsupported types are completely filtered out.
func TestFinalBlocker3_SupportedProviderTypes_UnsupportedTypesIgnored(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Create ONLY unsupported providers
unsupportedTypes := []string{"sms", "generic"}
for _, providerType := range unsupportedTypes {
provider := &models.NotificationProvider{
Name: providerType + " provider",
Type: providerType,
Enabled: true,
NotifySecurityWAFBlocks: true,
NotifySecurityACLDenies: true,
}
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)
// All providers are unsupported, so all flags should be false
assert.False(t, response.NotifyWAFBlocks, "WAF should be disabled (no supported providers)")
assert.False(t, response.NotifyACLDenies, "ACL should be disabled (no supported providers)")
assert.False(t, response.NotifyRateLimitHits, "Rate limit should be disabled (no supported providers)")
}
// TestBlocker2_GETReturnsSecurityFields tests GET returns security_* fields per spec.
// Blocker 2: Compatibility endpoint contract must use explicit security_* payload fields per spec.
func TestBlocker2_GETReturnsSecurityFields(t *testing.T) {
db := SetupCompatibilityTestDB(t)
provider := &models.NotificationProvider{
Name: "Test Provider",
Type: "webhook",
Enabled: true,
NotifySecurityWAFBlocks: true,
NotifySecurityACLDenies: false,
NotifySecurityRateLimitHits: true,
}
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)
// Blocker 2 Fix: Verify API returns security_* field names per spec
var rawResponse map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &rawResponse)
require.NoError(t, err)
// Check field names in JSON (not Go struct field names)
assert.Equal(t, true, rawResponse["security_waf_enabled"], "API should return security_waf_enabled=true")
assert.Equal(t, false, rawResponse["security_acl_enabled"], "API should return security_acl_enabled=false")
assert.Equal(t, true, rawResponse["security_rate_limit_enabled"], "API should return security_rate_limit_enabled=true")
// Verify notify_* fields are NOT present (spec drift check)
_, hasNotifyWAF := rawResponse["notify_waf_blocks"]
assert.False(t, hasNotifyWAF, "API should NOT expose notify_waf_blocks (use security_waf_enabled)")
}
// TestBlocker2_GotifyTokenNeverExposed_Legacy tests that gotify token is never exposed in GET responses.
func TestBlocker2_GotifyTokenNeverExposed_Legacy(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Create gotify provider with token
provider := &models.NotificationProvider{
Name: "Gotify Provider",
Type: "gotify",
URL: "https://gotify.example.com",
Token: "secret_gotify_token_xyz",
Enabled: true,
ManagedLegacySecurity: true,
NotifySecurityWAFBlocks: true,
}
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)
// Blocker 2: Gotify token must NEVER be exposed in GET responses
assert.Empty(t, response.GotifyToken, "Gotify token must not be exposed in GET response")
assert.Equal(t, "https://gotify.example.com", response.GotifyURL, "Gotify URL should be returned")
}
// TestBlocker3_PUTIdempotency tests that identical PUT requests do not mutate timestamps.
func TestBlocker3_PUTIdempotency(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Create managed provider
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)
// Read initial timestamps
var initial models.NotificationProvider
require.NoError(t, db.First(&initial, "id = ?", managed.ID).Error)
initialUpdatedAt := initial.UpdatedAt
service := services.NewEnhancedSecurityNotificationService(db)
// Perform identical PUT
req := &models.NotificationConfig{
NotifyWAFBlocks: true,
NotifyACLDenies: false,
NotifyRateLimitHits: true,
}
err := service.UpdateSettings(req)
require.NoError(t, err)
// Read timestamps again
var afterPUT models.NotificationProvider
require.NoError(t, db.First(&afterPUT, "id = ?", managed.ID).Error)
// Blocker 3: Timestamps must NOT change when effective values are identical
assert.Equal(t, initialUpdatedAt.Unix(), afterPUT.UpdatedAt.Unix(), "UpdatedAt should not change for identical PUT")
}
// TestBlocker4_MultipleManagedProvidersAllowed tests that multiple managed providers are updated (not 409).
func TestBlocker4_MultipleManagedProvidersAllowed(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Create two managed providers (simulating migration state)
managed1 := &models.NotificationProvider{
Name: "Managed Provider 1",
Type: "webhook",
Enabled: true,
ManagedLegacySecurity: true,
NotifySecurityWAFBlocks: false,
NotifySecurityACLDenies: false,
NotifySecurityRateLimitHits: false,
}
managed2 := &models.NotificationProvider{
Name: "Managed Provider 2",
Type: "discord",
Enabled: true,
ManagedLegacySecurity: true,
NotifySecurityWAFBlocks: false,
NotifySecurityACLDenies: false,
NotifySecurityRateLimitHits: false,
}
require.NoError(t, db.Create(managed1).Error)
require.NoError(t, db.Create(managed2).Error)
service := services.NewEnhancedSecurityNotificationService(db)
// Perform PUT - should update ALL managed providers (not 409)
req := &models.NotificationConfig{
NotifyWAFBlocks: true,
NotifyACLDenies: true,
NotifyRateLimitHits: false,
}
err := service.UpdateSettings(req)
require.NoError(t, err, "Multiple managed providers should be allowed and updated")
// Verify both providers were updated
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.True(t, updated1.NotifySecurityWAFBlocks)
assert.True(t, updated1.NotifySecurityACLDenies)
assert.False(t, updated1.NotifySecurityRateLimitHits)
assert.True(t, updated2.NotifySecurityWAFBlocks)
assert.True(t, updated2.NotifySecurityACLDenies)
assert.False(t, updated2.NotifySecurityRateLimitHits)
}
// TestBlocker1_FeatureFlagDefaultProduction tests that prod environment defaults to false.
func TestBlocker1_FeatureFlagDefaultProduction(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Clear the auto-created feature flag
db.Where("key = ?", "feature.notifications.security_provider_events.enabled").Delete(&models.Setting{})
// Set CHARON_ENV=production
require.NoError(t, os.Setenv("CHARON_ENV", "production"))
defer func() { _ = os.Unsetenv("CHARON_ENV") }()
service := services.NewEnhancedSecurityNotificationService(db)
// Trigger flag initialization
_, err := service.GetSettings()
require.NoError(t, err)
// Verify flag was created with false default
var setting models.Setting
err = db.Where("key = ?", "feature.notifications.security_provider_events.enabled").First(&setting).Error
require.NoError(t, err)
assert.Equal(t, "false", setting.Value, "Production must default to false")
}
// TestBlocker1_FeatureFlagDefaultProductionUnsetEnv tests prod default when no env vars set.
func TestBlocker1_FeatureFlagDefaultProductionUnsetEnv(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Clear the auto-created feature flag
db.Where("key = ?", "feature.notifications.security_provider_events.enabled").Delete(&models.Setting{})
// Clear both CHARON_ENV and GIN_MODE to simulate production without explicit env vars
_ = os.Unsetenv("CHARON_ENV")
_ = os.Unsetenv("GIN_MODE")
service := services.NewEnhancedSecurityNotificationService(db)
// Trigger flag initialization
_, err := service.GetSettings()
require.NoError(t, err)
// Blocker 1 Fix: When no env vars set, must default to false (production)
var setting models.Setting
err = db.Where("key = ?", "feature.notifications.security_provider_events.enabled").First(&setting).Error
require.NoError(t, err)
assert.Equal(t, "false", setting.Value, "Unset env vars must default to false (production)")
}
// TestBlocker1_FeatureFlagDefaultDev tests that dev/test environment defaults to true.
func TestBlocker1_FeatureFlagDefaultDev(t *testing.T) {
db := SetupCompatibilityTestDB(t)
// Clear the auto-created feature flag
db.Where("key = ?", "feature.notifications.security_provider_events.enabled").Delete(&models.Setting{})
// Set GIN_MODE=test to simulate test environment
require.NoError(t, os.Setenv("GIN_MODE", "test"))
defer func() { _ = os.Unsetenv("GIN_MODE") }()
// Ensure CHARON_ENV is not set to production
_ = os.Unsetenv("CHARON_ENV")
service := services.NewEnhancedSecurityNotificationService(db)
// Trigger flag initialization
_, err := service.GetSettings()
require.NoError(t, err)
// Verify flag was created with true default (test mode)
var setting models.Setting
err = db.Where("key = ?", "feature.notifications.security_provider_events.enabled").First(&setting).Error
require.NoError(t, err)
assert.Equal(t, "true", setting.Value, "Dev/test must default to true")
}