package handlers import ( "bytes" "encoding/json" "errors" "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" ) // mockSecurityNotificationService implements the service interface for controlled testing. type mockSecurityNotificationService struct { getSettingsFunc func() (*models.NotificationConfig, error) updateSettingsFunc func(*models.NotificationConfig) error } func (m *mockSecurityNotificationService) GetSettings() (*models.NotificationConfig, error) { if m.getSettingsFunc != nil { return m.getSettingsFunc() } return &models.NotificationConfig{}, nil } func (m *mockSecurityNotificationService) UpdateSettings(c *models.NotificationConfig) error { if m.updateSettingsFunc != nil { return m.updateSettingsFunc(c) } return nil } func setupSecNotifTestDB(t *testing.T) *gorm.DB { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationConfig{})) return db } // TestNewSecurityNotificationHandler verifies constructor returns non-nil handler. func TestNewSecurityNotificationHandler(t *testing.T) { t.Parallel() db := setupSecNotifTestDB(t) svc := services.NewSecurityNotificationService(db) handler := NewSecurityNotificationHandler(svc) assert.NotNil(t, handler, "Handler should not be nil") } // TestSecurityNotificationHandler_GetSettings_Success tests successful settings retrieval. func TestSecurityNotificationHandler_GetSettings_Success(t *testing.T) { t.Parallel() expectedConfig := &models.NotificationConfig{ ID: "test-id", Enabled: true, MinLogLevel: "warn", WebhookURL: "https://example.com/webhook", NotifyWAFBlocks: true, NotifyACLDenies: false, } mockService := &mockSecurityNotificationService{ getSettingsFunc: func() (*models.NotificationConfig, error) { return expectedConfig, nil }, } handler := NewSecurityNotificationHandler(mockService) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/api/v1/security/notifications/settings", http.NoBody) handler.GetSettings(c) assert.Equal(t, http.StatusOK, w.Code) var config models.NotificationConfig err := json.Unmarshal(w.Body.Bytes(), &config) require.NoError(t, err) assert.Equal(t, expectedConfig.ID, config.ID) assert.Equal(t, expectedConfig.Enabled, config.Enabled) assert.Equal(t, expectedConfig.MinLogLevel, config.MinLogLevel) assert.Equal(t, expectedConfig.WebhookURL, config.WebhookURL) assert.Equal(t, expectedConfig.NotifyWAFBlocks, config.NotifyWAFBlocks) assert.Equal(t, expectedConfig.NotifyACLDenies, config.NotifyACLDenies) } // TestSecurityNotificationHandler_GetSettings_ServiceError tests service error handling. func TestSecurityNotificationHandler_GetSettings_ServiceError(t *testing.T) { t.Parallel() mockService := &mockSecurityNotificationService{ getSettingsFunc: func() (*models.NotificationConfig, error) { return nil, errors.New("database connection failed") }, } handler := NewSecurityNotificationHandler(mockService) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/api/v1/security/notifications/settings", http.NoBody) handler.GetSettings(c) assert.Equal(t, http.StatusInternalServerError, w.Code) var response map[string]string err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Contains(t, response["error"], "Failed to retrieve settings") } // TestSecurityNotificationHandler_UpdateSettings_InvalidJSON tests malformed JSON handling. func TestSecurityNotificationHandler_UpdateSettings_InvalidJSON(t *testing.T) { t.Parallel() mockService := &mockSecurityNotificationService{} handler := NewSecurityNotificationHandler(mockService) malformedJSON := []byte(`{enabled: true, "min_log_level": "error"`) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(malformedJSON)) c.Request.Header.Set("Content-Type", "application/json") handler.UpdateSettings(c) assert.Equal(t, http.StatusBadRequest, w.Code) var response map[string]string err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Contains(t, response["error"], "Invalid request body") } // TestSecurityNotificationHandler_UpdateSettings_InvalidMinLogLevel tests invalid log level rejection. func TestSecurityNotificationHandler_UpdateSettings_InvalidMinLogLevel(t *testing.T) { t.Parallel() invalidLevels := []struct { name string level string }{ {"trace", "trace"}, {"critical", "critical"}, {"fatal", "fatal"}, {"unknown", "unknown"}, } for _, tc := range invalidLevels { t.Run(tc.name, func(t *testing.T) { mockService := &mockSecurityNotificationService{} handler := NewSecurityNotificationHandler(mockService) config := models.NotificationConfig{ Enabled: true, MinLogLevel: tc.level, NotifyWAFBlocks: true, } body, err := json.Marshal(config) require.NoError(t, err) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body)) c.Request.Header.Set("Content-Type", "application/json") handler.UpdateSettings(c) assert.Equal(t, http.StatusBadRequest, w.Code) var response map[string]string err = json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Contains(t, response["error"], "Invalid min_log_level") }) } } // TestSecurityNotificationHandler_UpdateSettings_InvalidWebhookURL_SSRF tests SSRF protection. func TestSecurityNotificationHandler_UpdateSettings_InvalidWebhookURL_SSRF(t *testing.T) { t.Parallel() ssrfURLs := []struct { name string url string }{ {"AWS Metadata", "http://169.254.169.254/latest/meta-data/"}, {"GCP Metadata", "http://metadata.google.internal/computeMetadata/v1/"}, {"Azure Metadata", "http://169.254.169.254/metadata/instance"}, {"Private IP 10.x", "http://10.0.0.1/admin"}, {"Private IP 172.16.x", "http://172.16.0.1/config"}, {"Private IP 192.168.x", "http://192.168.1.1/api"}, {"Link-local", "http://169.254.1.1/"}, } for _, tc := range ssrfURLs { t.Run(tc.name, func(t *testing.T) { mockService := &mockSecurityNotificationService{} handler := NewSecurityNotificationHandler(mockService) config := models.NotificationConfig{ Enabled: true, MinLogLevel: "error", WebhookURL: tc.url, NotifyWAFBlocks: true, } body, err := json.Marshal(config) require.NoError(t, err) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body)) c.Request.Header.Set("Content-Type", "application/json") handler.UpdateSettings(c) assert.Equal(t, http.StatusBadRequest, w.Code) var response map[string]interface{} err = json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Contains(t, response["error"], "Invalid webhook URL") if help, ok := response["help"]; ok { assert.Contains(t, help, "private networks") } }) } } // TestSecurityNotificationHandler_UpdateSettings_PrivateIPWebhook tests private IP handling. func TestSecurityNotificationHandler_UpdateSettings_PrivateIPWebhook(t *testing.T) { t.Parallel() // Note: localhost is allowed by WithAllowLocalhost() option localhostURLs := []string{ "http://127.0.0.1/hook", "http://localhost/webhook", "http://[::1]/api", } for _, url := range localhostURLs { t.Run(url, func(t *testing.T) { mockService := &mockSecurityNotificationService{ updateSettingsFunc: func(c *models.NotificationConfig) error { return nil }, } handler := NewSecurityNotificationHandler(mockService) config := models.NotificationConfig{ Enabled: true, MinLogLevel: "warn", WebhookURL: url, } body, err := json.Marshal(config) require.NoError(t, err) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body)) c.Request.Header.Set("Content-Type", "application/json") handler.UpdateSettings(c) // Localhost should be allowed with AllowLocalhost option assert.Equal(t, http.StatusOK, w.Code, "Localhost should be allowed: %s", url) }) } } // TestSecurityNotificationHandler_UpdateSettings_ServiceError tests database error handling. func TestSecurityNotificationHandler_UpdateSettings_ServiceError(t *testing.T) { t.Parallel() mockService := &mockSecurityNotificationService{ updateSettingsFunc: func(c *models.NotificationConfig) error { return errors.New("database write failed") }, } handler := NewSecurityNotificationHandler(mockService) config := models.NotificationConfig{ Enabled: true, MinLogLevel: "error", WebhookURL: "http://localhost:9090/webhook", // Use localhost NotifyWAFBlocks: true, } body, err := json.Marshal(config) require.NoError(t, err) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body)) c.Request.Header.Set("Content-Type", "application/json") handler.UpdateSettings(c) assert.Equal(t, http.StatusInternalServerError, w.Code) var response map[string]string err = json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Contains(t, response["error"], "Failed to update settings") } // TestSecurityNotificationHandler_UpdateSettings_Success tests successful settings update. func TestSecurityNotificationHandler_UpdateSettings_Success(t *testing.T) { t.Parallel() var capturedConfig *models.NotificationConfig mockService := &mockSecurityNotificationService{ updateSettingsFunc: func(c *models.NotificationConfig) error { capturedConfig = c return nil }, } handler := NewSecurityNotificationHandler(mockService) config := models.NotificationConfig{ Enabled: true, MinLogLevel: "warn", WebhookURL: "http://localhost:8080/security", // Use localhost which is allowed NotifyWAFBlocks: true, NotifyACLDenies: false, } body, err := json.Marshal(config) require.NoError(t, err) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body)) c.Request.Header.Set("Content-Type", "application/json") handler.UpdateSettings(c) assert.Equal(t, http.StatusOK, w.Code) var response map[string]string err = json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Equal(t, "Settings updated successfully", response["message"]) // Verify the service was called with the correct config require.NotNil(t, capturedConfig) assert.Equal(t, config.Enabled, capturedConfig.Enabled) assert.Equal(t, config.MinLogLevel, capturedConfig.MinLogLevel) assert.Equal(t, config.WebhookURL, capturedConfig.WebhookURL) assert.Equal(t, config.NotifyWAFBlocks, capturedConfig.NotifyWAFBlocks) assert.Equal(t, config.NotifyACLDenies, capturedConfig.NotifyACLDenies) } // TestSecurityNotificationHandler_UpdateSettings_EmptyWebhookURL tests empty webhook is valid. func TestSecurityNotificationHandler_UpdateSettings_EmptyWebhookURL(t *testing.T) { t.Parallel() mockService := &mockSecurityNotificationService{ updateSettingsFunc: func(c *models.NotificationConfig) error { return nil }, } handler := NewSecurityNotificationHandler(mockService) config := models.NotificationConfig{ Enabled: true, MinLogLevel: "info", WebhookURL: "", NotifyWAFBlocks: true, NotifyACLDenies: true, } body, err := json.Marshal(config) require.NoError(t, err) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body)) c.Request.Header.Set("Content-Type", "application/json") handler.UpdateSettings(c) assert.Equal(t, http.StatusOK, w.Code) var response map[string]string err = json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Equal(t, "Settings updated successfully", response["message"]) }