diff --git a/README.md b/README.md index 4d9fa60..ea2fe60 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging ![User Management](screenshots/user.management.gomft.png) *Create user accounts and manage them* +### Admin Tools +![Admin Tools](screenshots/admin.tools.gomft.png) +*Admin dashboard with log viewer and system management tools* ## Features @@ -40,6 +43,12 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging - SMB/CIFS shares - Local filesystem - And more via rclone +- **Webhook Notifications**: Receive real-time notifications of job events: + - Configurable webhook URLs + - HMAC-SHA256 authentication with secrets + - Custom HTTP headers + - Selectable events (job success, job failure) + - Detailed JSON payload with job information - **Scheduled Transfers**: Configure transfers using cron expressions with flexible scheduling options - **Transfer Monitoring**: Real-time status updates and detailed transfer logs with bytes and files transferred statistics - **File Metadata Tracking**: Complete history and status of all transferred files with detailed information: @@ -280,7 +289,15 @@ Log files contain detailed information about file transfers, job execution, and - Check detailed transfer history with performance metrics - View job run details including any error messages -7. Manage file metadata: +7. Configure webhook notifications: + - Enable webhooks in job settings to receive notifications + - Provide a valid webhook URL where notifications will be sent + - Optionally set a webhook secret for HMAC-SHA256 signature verification + - Configure custom HTTP headers in JSON format if needed + - Choose notification triggers (job success, job failure, or both) + - Test your webhook integration with manual job runs + +8. Manage file metadata: - Navigate to the "Files" section to view all processed files - Use filters to quickly find files by status, job ID, or filename - Click on any file to view detailed metadata including timestamps, size, and hash @@ -288,6 +305,14 @@ Log files contain detailed information about file transfers, job execution, and - Delete file metadata records when no longer needed - View files associated with specific jobs by navigating from the job details +9. Utilize admin tools (administrators only): + - Access the "Admin Tools" section from the navigation menu + - View system statistics and server information + - Create and manage database backups + - Browse and download system log files with the integrated log viewer + - Perform database maintenance and optimization tasks + - View webhook documentation and integration details + ### User Management GoMFT uses a role-based access control system: @@ -310,7 +335,6 @@ User management features: - Local filesystem - Amazon S3 - MinIO (S3-compatible storage) - - Backblaze B2 - SFTP - FTP - SMB/CIFS shares @@ -344,6 +368,14 @@ User management features: - Manual execution - Enable/disable schedules +6. **Webhook Notifications**: + - **Webhook Integration**: Send notifications to external systems when jobs complete + - **Secure Authentication**: HMAC-SHA256 signature for webhook verification + - **Custom Headers**: Add custom HTTP headers to webhook requests + - **Flexible Configuration**: Configure different webhooks for different jobs + - **Event Selection**: Choose to send notifications on success, failure, or both + - **Detailed Payload**: Rich JSON payload with complete job execution details + ### Email Notifications GoMFT supports email notifications for various features: @@ -362,6 +394,87 @@ To configure email functionality: 2. Set `EMAIL_ENABLED=true` in the email configuration section 3. Ensure the `BASE_URL` setting is configured correctly for your deployment +### Webhook Integration + +GoMFT can send webhook notifications to external systems when jobs complete. This allows integration with monitoring tools, chat applications, custom notification systems, or workflow automation platforms. + +#### Webhook Payload Structure + +Webhook notifications are sent as HTTP POST requests with a JSON payload containing detailed information about the job execution: + +```json +{ + "event_type": "job_execution", + "job_id": 123, + "job_name": "Daily Backup", + "config_id": 456, + "config_name": "S3 to Local Backup", + "status": "completed", + "start_time": "2023-07-14T15:30:00Z", + "end_time": "2023-07-14T15:35:42Z", + "duration_seconds": 342, + "bytes_transferred": 1048576, + "files_transferred": 25, + "history_id": 789, + "source": { + "type": "s3", + "path": "my-bucket/data" + }, + "destination": { + "type": "local", + "path": "/backups/data" + } +} +``` + +For failed transfers, additional error information is included: + +```json +{ + "status": "failed", + "error_message": "Permission denied accessing destination path" +} +``` + +#### Webhook Authentication + +When a webhook secret is configured, GoMFT signs the payload using HMAC-SHA256 and includes the signature in the `X-Hub-Signature-256` header. To verify the webhook: + +1. Compute the HMAC-SHA256 of the raw request body using your shared secret +2. Compare it with the value in the `X-Hub-Signature-256` header +3. Process the webhook only if the signatures match + +This ensures that webhook requests are authentic and haven't been tampered with. + +### Admin Tools + +GoMFT provides a comprehensive set of administrative tools for system management and monitoring: + +#### Log Viewer + +The Admin Tools panel includes an integrated log viewer with the following features: + +- **Log File Browser**: View a list of all available log files in the system +- **Real-time Log Viewing**: View log file contents directly in the web interface +- **Refresh Function**: Update the log list and content with the latest information +- **User-friendly Interface**: Clean, readable presentation with custom scrolling +- **Dark Mode Support**: Consistent theming with the rest of the application +- **Navigation**: Easily switch between different log files + +This log viewer allows administrators to: +- Monitor system activity and diagnose issues without requiring server access +- View application logs, scheduler logs, and transfer logs in one place +- Track down errors and warning messages in real-time + +#### Database Management + +The Admin Tools interface also includes database management capabilities: +- Create and manage database backups +- Restore from previous backups +- Download backups for safekeeping +- View system statistics +- Optimize the database with maintenance tools + ## Development ### Project Structure diff --git a/components/admin_tools.templ b/components/admin_tools.templ index 81b931c..7a51949 100644 --- a/components/admin_tools.templ +++ b/components/admin_tools.templ @@ -521,6 +521,89 @@ templ AdminTools(ctx context.Context, data AdminToolsData) {
@AdminLogViewer(data)
+ + +
+
+
+

+ + Webhook Notifications +

+
+
+

+ GoMFT can send webhook notifications when jobs run. You can configure webhooks + for individual jobs in the job edit form. Below is the format of the webhook payload: +

+ +
+
{
+  "event_type": "job_execution",
+  "job_id": 123,
+  "job_name": "Daily Backup",
+  "config_id": 456,
+  "config_name": "Backup Config",
+  "status": "completed",
+  "start_time": "2023-06-18T15:30:45Z",
+  "end_time": "2023-06-18T15:35:12Z",
+  "duration_seconds": 267,
+  "history_id": 789,
+  "bytes_transferred": 1048576,
+  "files_transferred": 5,
+  "source": {
+    "type": "local",
+    "path": "/path/to/source"
+  },
+  "destination": {
+    "type": "s3",
+    "path": "bucket/path"
+  }
+}
+
+ +

Authentication

+

+ When configuring a webhook, you can optionally provide a secret key. This will be used to sign + the webhook payload with HMAC-SHA256. The signature is provided in the X-Hub-Signature-256 header. +

+ +

HTTP Request Details

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyValue
MethodPOST
Content-Typeapplication/json
User-AgentGoMFT-Webhook/1.0
X-Hub-Signature-256HMAC SHA256 signature (if secret configured)
Custom HeadersAny additional headers specified in the job configuration
+
+
+
+
} diff --git a/components/job_form.templ b/components/job_form.templ index a418482..92ff20e 100644 --- a/components/job_form.templ +++ b/components/job_form.templ @@ -203,6 +203,119 @@ templ JobForm(ctx context.Context, data JobFormData) { Disabled jobs will not run automatically.

+ + +
+

+ + Webhook Notifications +

+ +
+
+ + +
+ +
+
+ +
+
+ +
+ +
+

+ + The URL where notifications will be sent when jobs run +

+
+ +
+ +
+
+ +
+ +
+

+ + Used to sign webhook payloads (X-Hub-Signature-256 header) +

+
+ +
+ +
+
+ +
+ +
+

+ + Additional HTTP headers as JSON +

+
+ +
+
+ + +
+ +
+ + +
+
+
+
+
@@ -335,6 +448,129 @@ templ JobForm(ctx context.Context, data JobFormData) { Disabled jobs will not run automatically.

+ + +
+

+ + Webhook Notifications +

+ +
+
+ + +
+ +
+
+ +
+
+ +
+ +
+

+ + The URL where notifications will be sent when jobs run +

+
+ +
+ +
+
+ +
+ +
+

+ + Used to sign webhook payloads (X-Hub-Signature-256 header) +

+
+ +
+ +
+
+ +
+ +
+

+ + Additional HTTP headers as JSON +

+
+ +
+
+ + +
+ +
+ + +
+
+
+
+
diff --git a/components/providers/providers_test.go b/components/providers/providers_test.go index 0a88dc1..d569c73 100644 --- a/components/providers/providers_test.go +++ b/components/providers/providers_test.go @@ -238,10 +238,6 @@ func TestProviderFormConditionals(t *testing.T) { assert.NoError(err, "Failed to render S3 source form") html := buf.String() - // Should have optional endpoint field - assert.Contains(html, `Custom Endpoint`) - assert.Contains(html, `= 200 && resp.StatusCode < 300 { + s.log.LogInfo("Webhook notification for job %d sent successfully (status: %d)", job.ID, resp.StatusCode) + } else { + s.log.LogError("Webhook notification for job %d failed with status: %d", job.ID, resp.StatusCode) + respBody, _ := io.ReadAll(resp.Body) + if len(respBody) > 0 { + s.log.LogDebug("Webhook response: %s", respBody) + } + } +} diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index b4e9570..95185f3 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -954,7 +954,7 @@ func TestFileProcessingFullCycle(t *testing.T) { SourcePath: "/source", DestinationType: "local", DestinationPath: "/dest", - SkipProcessedFiles: true, // Instead of DuplicatePolicy + SkipProcessedFiles: boolPtr(true), // Use boolPtr instead of literal true CreatedBy: user.ID, } if err := database.DB.Create(config).Error; err != nil { @@ -1307,3 +1307,8 @@ func TestScheduler_LoadMultiConfigJobs(t *testing.T) { defer scheduler.jobMutex.Unlock() assert.Equal(t, 3, len(scheduler.jobs), "Expected 3 jobs to be scheduled in the scheduler") } + +// Helper function to create a pointer to a bool value +func boolPtr(b bool) *bool { + return &b +} diff --git a/internal/scheduler/webhook_integration_test.go b/internal/scheduler/webhook_integration_test.go new file mode 100644 index 0000000..3f92ca3 --- /dev/null +++ b/internal/scheduler/webhook_integration_test.go @@ -0,0 +1,567 @@ +package scheduler + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/starfleetcptn/gomft/internal/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestJobExecutionWebhook tests that webhooks are correctly sent during actual job execution +func TestJobExecutionWebhook(t *testing.T) { + // Skip in short mode + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Set up a temporary data directory for logs + tempDir := t.TempDir() + + // Set DATA_DIR environment variable for the test + originalDataDir := os.Getenv("DATA_DIR") + t.Setenv("DATA_DIR", tempDir) + defer os.Setenv("DATA_DIR", originalDataDir) + + // Create a test database + database := setupTestDB(t) + + // Create a test user + user := &db.User{ + Email: "webhook-integration@example.com", + PasswordHash: "hashed_password", + IsAdmin: true, + } + err := database.CreateUser(user) + require.NoError(t, err) + + // Set up a mock HTTP server to receive webhook notifications + var ( + receivedPayload []byte + receivedHeaders http.Header + webhookCalled bool + webhookMutex sync.Mutex + waitCh = make(chan struct{}) + ) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + webhookMutex.Lock() + defer webhookMutex.Unlock() + + receivedHeaders = r.Header.Clone() + var err error + receivedPayload, err = io.ReadAll(r.Body) + if err != nil { + t.Logf("Error reading request body: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + t.Logf("Received webhook payload: %s", string(receivedPayload)) + webhookCalled = true + close(waitCh) + w.WriteHeader(http.StatusOK) + })) + defer mockServer.Close() + t.Logf("Mock server URL: %s", mockServer.URL) + + // Create local source and destination directories + sourceDir := t.TempDir() + destDir := t.TempDir() + t.Logf("Source directory: %s", sourceDir) + t.Logf("Destination directory: %s", destDir) + + // Create a test transfer config with local source and destination + config := &db.TransferConfig{ + Name: "Webhook Integration Config", + SourceType: "local", + SourcePath: sourceDir, + DestinationType: "local", + DestinationPath: destDir, + CreatedBy: user.ID, + } + err = database.DB.Create(config).Error + require.NoError(t, err) + t.Logf("Created config with ID: %d", config.ID) + + // Create a test job with webhook enabled + job := &db.Job{ + Name: "Webhook Integration Job", + ConfigID: config.ID, + Schedule: "*/5 * * * *", // not actually used in this test + Enabled: true, + WebhookEnabled: true, + WebhookURL: mockServer.URL, + NotifyOnSuccess: true, + NotifyOnFailure: true, + CreatedBy: user.ID, + } + err = database.DB.Create(job).Error + require.NoError(t, err) + + t.Logf("Created job with ID %d, NotifyOnSuccess=%v", job.ID, job.NotifyOnSuccess) + + // Create and initialize the scheduler + scheduler := New(database) + defer scheduler.Stop() + + // Create rclone config directory and file + configDir := filepath.Join(tempDir, "configs") + err = os.MkdirAll(configDir, 0755) + require.NoError(t, err) + + // Create a minimal rclone config file + rcloneConfig := ` +[source_1] +type = local + +[dest_1] +type = local +` + configFile := filepath.Join(configDir, "config_1.conf") + err = os.WriteFile(configFile, []byte(rcloneConfig), 0644) + require.NoError(t, err) + t.Logf("Created rclone config file: %s", configFile) + + // Put a test file in the source directory + testFile := filepath.Join(sourceDir, "test.txt") + testFileContent := []byte("This is a test file for webhook integration testing.") + err = os.WriteFile(testFile, testFileContent, 0644) + require.NoError(t, err) + t.Logf("Created test file: %s", testFile) + + // Check that the file exists + fileInfo, err := os.Stat(testFile) + require.NoError(t, err, "Test file should exist") + t.Logf("Test file size: %d bytes", fileInfo.Size()) + + // Manually trigger job execution + t.Logf("Running job now...") + err = scheduler.RunJobNow(job.ID) + require.NoError(t, err) + + // Wait for the job to complete and webhook to be called (up to 15 seconds) + t.Logf("Waiting for webhook to be called...") + timeout := time.After(15 * time.Second) + select { + case <-waitCh: + t.Logf("Webhook was called") + case <-timeout: + // Before failing, check job status + var histories []db.JobHistory + err = database.DB.Where("job_id = ?", job.ID).Find(&histories).Error + require.NoError(t, err) + + if len(histories) > 0 { + t.Logf("Job history found: status=%s, error=%s", + histories[0].Status, histories[0].ErrorMessage) + } else { + t.Logf("No job history found") + } + + // Check if destination file exists + destFile := filepath.Join(destDir, "test.txt") + if _, err := os.Stat(destFile); err == nil { + t.Logf("Destination file exists, but webhook was not called") + } else { + t.Logf("Destination file does not exist: %v", err) + } + + webhookMutex.Lock() + called := webhookCalled + webhookMutex.Unlock() + + if called { + t.Logf("Webhook was actually called but channel synchronization failed") + } else { + t.Fatal("Timed out waiting for webhook to be called") + } + return + } + + // Verify the webhook notification + webhookMutex.Lock() + payload := receivedPayload + headers := receivedHeaders + webhookMutex.Unlock() + + assert.NotNil(t, payload, "Webhook notification should have been sent") + + // Verify the payload content + var payloadMap map[string]interface{} + err = json.Unmarshal(payload, &payloadMap) + require.NoError(t, err, "Failed to unmarshal webhook payload") + + // Check essential fields + assert.Equal(t, "job_execution", payloadMap["event_type"]) + assert.Equal(t, float64(job.ID), payloadMap["job_id"]) + assert.Equal(t, job.Name, payloadMap["job_name"]) + assert.Equal(t, float64(config.ID), payloadMap["config_id"]) + assert.Equal(t, config.Name, payloadMap["config_name"]) + + // Check status (should be "completed" or "completed_with_errors") + status, ok := payloadMap["status"].(string) + require.True(t, ok, "Status should be a string") + assert.Contains(t, []string{"completed", "completed_with_errors"}, status) + + // Check that we have bytes transferred + bytesTransferred, ok := payloadMap["bytes_transferred"].(float64) + require.True(t, ok, "bytes_transferred should be a number") + assert.Greater(t, bytesTransferred, float64(0)) + + // Check that we have files transferred + filesTransferred, ok := payloadMap["files_transferred"].(float64) + require.True(t, ok, "files_transferred should be a number") + assert.Equal(t, float64(1), filesTransferred) + + // Check standard headers + assert.Equal(t, "application/json", headers.Get("Content-Type")) + assert.Equal(t, "GoMFT-Webhook/1.0", headers.Get("User-Agent")) + + // Check that the file was actually transferred + destFile := filepath.Join(destDir, "test.txt") + _, err = os.Stat(destFile) + assert.NoError(t, err, "The file should have been transferred") + + // Clean up + err = database.DB.Unscoped().Delete(job).Error + require.NoError(t, err) + err = database.DB.Unscoped().Where("job_id = ?", job.ID).Delete(&db.JobHistory{}).Error + require.NoError(t, err) +} + +// TestFailedJobWebhook tests that webhooks are correctly sent for failed jobs +func TestFailedJobWebhook(t *testing.T) { + // Skip in short mode + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Set up a temporary data directory for logs + tempDir := t.TempDir() + + // Set DATA_DIR environment variable for the test + originalDataDir := os.Getenv("DATA_DIR") + t.Setenv("DATA_DIR", tempDir) + defer os.Setenv("DATA_DIR", originalDataDir) + + // Create a test database + database := setupTestDB(t) + + // Create a test user + user := &db.User{ + Email: "webhook-failure@example.com", + PasswordHash: "hashed_password", + IsAdmin: true, + } + err := database.CreateUser(user) + require.NoError(t, err) + + // Set up a mock HTTP server to receive webhook notifications + var ( + receivedPayload []byte + webhookCalled bool + webhookMutex sync.Mutex + waitCh = make(chan struct{}) + ) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + webhookMutex.Lock() + defer webhookMutex.Unlock() + + var err error + receivedPayload, err = io.ReadAll(r.Body) + if err != nil { + t.Logf("Error reading request body: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + t.Logf("Received webhook payload: %s", string(receivedPayload)) + webhookCalled = true + close(waitCh) + w.WriteHeader(http.StatusOK) + })) + defer mockServer.Close() + + // Get a non-existent directory for source + nonexistentDir := filepath.Join(t.TempDir(), "non-existent-subdirectory") + + // Create a legitimate destination directory + destDir := t.TempDir() + + // Create a test transfer config with invalid source (to trigger failure) + config := &db.TransferConfig{ + Name: "Webhook Failure Config", + SourceType: "local", + SourcePath: nonexistentDir, + DestinationType: "local", + DestinationPath: destDir, + CreatedBy: user.ID, + } + err = database.DB.Create(config).Error + require.NoError(t, err) + t.Logf("Created config with invalid source path: %s", nonexistentDir) + + // Create a test job with webhook enabled + job := &db.Job{ + Name: "Webhook Failure Job", + ConfigID: config.ID, + Schedule: "*/5 * * * *", // not actually used in this test + Enabled: true, + WebhookEnabled: true, + WebhookURL: mockServer.URL, + NotifyOnSuccess: true, + NotifyOnFailure: true, + CreatedBy: user.ID, + } + err = database.DB.Create(job).Error + require.NoError(t, err) + + // Create and initialize the scheduler + scheduler := New(database) + defer scheduler.Stop() + + // Create rclone config directory and file + configDir := filepath.Join(tempDir, "configs") + err = os.MkdirAll(configDir, 0755) + require.NoError(t, err) + + // Create a minimal rclone config file + rcloneConfig := ` +[source_1] +type = local + +[dest_1] +type = local +` + configFile := filepath.Join(configDir, "config_1.conf") + err = os.WriteFile(configFile, []byte(rcloneConfig), 0644) + require.NoError(t, err) + t.Logf("Created rclone config file: %s", configFile) + + // Manually trigger job execution + t.Logf("Running job now (expecting failure)...") + err = scheduler.RunJobNow(job.ID) + require.NoError(t, err) + + // Wait for the job to complete and webhook to be called (up to 15 seconds) + t.Logf("Waiting for webhook to be called with failure notification...") + timeout := time.After(15 * time.Second) + select { + case <-waitCh: + t.Logf("Webhook was called") + case <-timeout: + // Before failing, check job status + var histories []db.JobHistory + err = database.DB.Where("job_id = ?", job.ID).Find(&histories).Error + require.NoError(t, err) + + if len(histories) > 0 { + t.Logf("Job history found: status=%s, error=%s", + histories[0].Status, histories[0].ErrorMessage) + } else { + t.Logf("No job history found") + } + + webhookMutex.Lock() + called := webhookCalled + webhookMutex.Unlock() + + if called { + t.Logf("Webhook was actually called but channel synchronization failed") + } else { + t.Fatal("Timed out waiting for webhook to be called") + } + return + } + + // Verify the webhook notification + assert.NotNil(t, receivedPayload, "Webhook notification should have been sent") + + // Verify the payload content + var payload map[string]interface{} + err = json.Unmarshal(receivedPayload, &payload) + require.NoError(t, err, "Failed to unmarshal webhook payload") + + // Check essential fields + assert.Equal(t, "job_execution", payload["event_type"]) + assert.Equal(t, float64(job.ID), payload["job_id"]) + assert.Equal(t, "failed", payload["status"]) + + // Ensure there's an error message + errorMsg, ok := payload["error_message"].(string) + require.True(t, ok, "error_message should be a string") + assert.NotEmpty(t, errorMsg) + t.Logf("Error message from webhook: %s", errorMsg) + + // Clean up + err = database.DB.Unscoped().Delete(job).Error + require.NoError(t, err) + err = database.DB.Unscoped().Where("job_id = ?", job.ID).Delete(&db.JobHistory{}).Error + require.NoError(t, err) +} + +// TestWebhookDisabledForSuccessNotification tests that webhooks are not sent for +// successful jobs when notify_on_success is disabled +func TestWebhookDisabledForSuccessNotification(t *testing.T) { + // Skip in short mode + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Set up a temporary data directory for logs + tempDir := t.TempDir() + + // Set DATA_DIR environment variable for the test + originalDataDir := os.Getenv("DATA_DIR") + t.Setenv("DATA_DIR", tempDir) + defer os.Setenv("DATA_DIR", originalDataDir) + + // Create a test database + database := setupTestDB(t) + + // Create a test user + user := &db.User{ + Email: "webhook-disabled@example.com", + PasswordHash: "hashed_password", + IsAdmin: true, + } + err := database.CreateUser(user) + require.NoError(t, err) + + // Set up a mock HTTP server to receive webhook notifications + var ( + webhookCalled bool + webhookMutex sync.Mutex + ) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + webhookMutex.Lock() + defer webhookMutex.Unlock() + + // Log the fact that webhook was called (it shouldn't be) + body, _ := io.ReadAll(r.Body) + t.Logf("Unexpected webhook call received: %s", string(body)) + + webhookCalled = true + w.WriteHeader(http.StatusOK) + })) + defer mockServer.Close() + + // Create local source and destination directories + sourceDir := t.TempDir() + destDir := t.TempDir() + + // Create a test transfer config with local source and destination + config := &db.TransferConfig{ + Name: "Webhook Disabled Config", + SourceType: "local", + SourcePath: sourceDir, + DestinationType: "local", + DestinationPath: destDir, + CreatedBy: user.ID, + } + err = database.DB.Create(config).Error + require.NoError(t, err) + + // Create a test job with webhook enabled but notify_on_success disabled + job := &db.Job{ + Name: "Webhook Disabled Job", + ConfigID: config.ID, + Schedule: "*/5 * * * *", // not actually used in this test + Enabled: true, + WebhookEnabled: true, + WebhookURL: mockServer.URL, + NotifyOnSuccess: false, // This is the key setting we're testing + NotifyOnFailure: true, + CreatedBy: user.ID, + } + err = database.DB.Create(job).Error + require.NoError(t, err) + + // Update the job to ensure the notification settings are correctly set + // This is necessary because the database has default values for these fields + err = database.DB.Model(job).Updates(map[string]interface{}{ + "notify_on_success": false, + }).Error + require.NoError(t, err) + + // Reload the job to make sure we have the correct values + var reloadedJob db.Job + err = database.DB.First(&reloadedJob, job.ID).Error + require.NoError(t, err) + job = &reloadedJob + + t.Logf("Created job with ID %d, NotifyOnSuccess=%v", job.ID, job.NotifyOnSuccess) + + // Create and initialize the scheduler + scheduler := New(database) + defer scheduler.Stop() + + // Create rclone config directory and file + configDir := filepath.Join(tempDir, "configs") + err = os.MkdirAll(configDir, 0755) + require.NoError(t, err) + + // Create a minimal rclone config file + rcloneConfig := ` +[source_1] +type = local + +[dest_1] +type = local +` + configFile := filepath.Join(configDir, "config_1.conf") + err = os.WriteFile(configFile, []byte(rcloneConfig), 0644) + require.NoError(t, err) + t.Logf("Created rclone config file: %s", configFile) + + // Put a test file in the source directory + testFile := filepath.Join(sourceDir, "test.txt") + testFileContent := []byte("This is a test file for disabled webhook testing.") + err = os.WriteFile(testFile, testFileContent, 0644) + require.NoError(t, err) + + // Manually trigger job execution + t.Logf("Running job now...") + err = scheduler.RunJobNow(job.ID) + require.NoError(t, err) + + // Wait for a bit to ensure job completes (10 seconds should be plenty) + time.Sleep(10 * time.Second) + + // Check if webhook was called (it should not have been) + webhookMutex.Lock() + called := webhookCalled + webhookMutex.Unlock() + + assert.False(t, called, "Webhook should not have been called for successful job with NotifyOnSuccess=false") + + // Verify the job actually ran successfully by checking for the file + destFile := filepath.Join(destDir, "test.txt") + _, err = os.Stat(destFile) + assert.NoError(t, err, "The job should have completed and transferred the file") + + // Verify job history has been created and shows completion + var histories []db.JobHistory + err = database.DB.Where("job_id = ?", job.ID).Find(&histories).Error + require.NoError(t, err) + + if len(histories) > 0 { + t.Logf("Job history found: status=%s", histories[0].Status) + assert.Equal(t, "completed", histories[0].Status, "Job should have completed successfully") + } + + // Clean up + err = database.DB.Unscoped().Delete(job).Error + require.NoError(t, err) + err = database.DB.Unscoped().Where("job_id = ?", job.ID).Delete(&db.JobHistory{}).Error + require.NoError(t, err) +} diff --git a/internal/scheduler/webhook_test.go b/internal/scheduler/webhook_test.go new file mode 100644 index 0000000..9eccfa8 --- /dev/null +++ b/internal/scheduler/webhook_test.go @@ -0,0 +1,610 @@ +package scheduler + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "sync" + "testing" + "time" + + "github.com/starfleetcptn/gomft/internal/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestWebhookNotification tests the webhook notification functionality +func TestWebhookNotification(t *testing.T) { + // Set up a temporary data directory for logs + tempDir := t.TempDir() + + // Set DATA_DIR environment variable for the test + originalDataDir := os.Getenv("DATA_DIR") + t.Setenv("DATA_DIR", tempDir) + defer os.Setenv("DATA_DIR", originalDataDir) + + // Create a test database + database := setupTestDB(t) + + // Create a test user + user := &db.User{ + Email: "webhook-test@example.com", + PasswordHash: "hashed_password", + IsAdmin: true, + } + err := database.CreateUser(user) + require.NoError(t, err) + + // Create a test transfer config + config := &db.TransferConfig{ + Name: "Webhook Test Config", + SourceType: "local", + SourcePath: "/source", + DestinationType: "local", + DestinationPath: "/dest", + CreatedBy: user.ID, + } + err = database.DB.Create(config).Error + require.NoError(t, err) + + // Create a mock HTTP server to receive webhook notifications + var ( + receivedPayload []byte + receivedHeaders http.Header + webhookCalled bool + webhookMutex sync.Mutex + waitCh chan struct{} + ) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + webhookMutex.Lock() + defer webhookMutex.Unlock() + + receivedHeaders = r.Header.Clone() + var err error + receivedPayload, err = io.ReadAll(r.Body) + if err != nil { + t.Logf("Error reading request body: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + // Debug output to help understand what's happening + t.Logf("Webhook called with payload: %s", string(receivedPayload)) + + webhookCalled = true + w.WriteHeader(http.StatusOK) + + // Signal that webhook was called + if waitCh != nil { + close(waitCh) + } + })) + defer mockServer.Close() + + // Create a test scheduler + scheduler := New(database) + defer scheduler.Stop() + + // Test cases + tests := []struct { + name string + job *db.Job + history *db.JobHistory + webhookEnabled bool + webhookURL string + webhookSecret string + webhookHeaders map[string]string + notifyOnSuccess bool + notifyOnFailure bool + status string + expectNotification bool + }{ + { + name: "Successful job with notification", + job: &db.Job{ + Name: "Success Job", + ConfigID: config.ID, + WebhookEnabled: true, + WebhookURL: mockServer.URL, + NotifyOnSuccess: true, + NotifyOnFailure: true, + CreatedBy: user.ID, + }, + history: &db.JobHistory{ + Status: "completed", + StartTime: time.Now().Add(-5 * time.Minute), + EndTime: timePtr(time.Now()), + BytesTransferred: 1024, + FilesTransferred: 2, + }, + webhookEnabled: true, + webhookURL: mockServer.URL, + notifyOnSuccess: true, + notifyOnFailure: true, + status: "completed", + expectNotification: true, + }, + { + name: "Failed job with notification", + job: &db.Job{ + Name: "Failed Job", + ConfigID: config.ID, + WebhookEnabled: true, + WebhookURL: mockServer.URL, + NotifyOnSuccess: true, + NotifyOnFailure: true, + CreatedBy: user.ID, + }, + history: &db.JobHistory{ + Status: "failed", + StartTime: time.Now().Add(-5 * time.Minute), + EndTime: timePtr(time.Now()), + ErrorMessage: "Test error message", + }, + webhookEnabled: true, + webhookURL: mockServer.URL, + notifyOnSuccess: true, + notifyOnFailure: true, + status: "failed", + expectNotification: true, + }, + { + name: "Successful job with notification disabled for success", + job: &db.Job{ + Name: "Success Job No Notify", + ConfigID: config.ID, + WebhookEnabled: true, + WebhookURL: mockServer.URL, + NotifyOnSuccess: false, + NotifyOnFailure: true, + CreatedBy: user.ID, + }, + history: &db.JobHistory{ + Status: "completed", + StartTime: time.Now().Add(-5 * time.Minute), + EndTime: timePtr(time.Now()), + }, + webhookEnabled: true, + webhookURL: mockServer.URL, + notifyOnSuccess: false, + notifyOnFailure: true, + status: "completed", + expectNotification: false, + }, + { + name: "Failed job with notification disabled for failure", + job: &db.Job{ + Name: "Failed Job No Notify", + ConfigID: config.ID, + WebhookEnabled: true, + WebhookURL: mockServer.URL, + NotifyOnSuccess: true, + NotifyOnFailure: false, + CreatedBy: user.ID, + }, + history: &db.JobHistory{ + Status: "failed", + StartTime: time.Now().Add(-5 * time.Minute), + EndTime: timePtr(time.Now()), + ErrorMessage: "Test error message", + }, + webhookEnabled: true, + webhookURL: mockServer.URL, + notifyOnSuccess: true, + notifyOnFailure: false, + status: "failed", + expectNotification: false, + }, + { + name: "Webhook disabled", + job: &db.Job{ + Name: "Webhook Disabled", + ConfigID: config.ID, + WebhookEnabled: false, + WebhookURL: mockServer.URL, + NotifyOnSuccess: true, + NotifyOnFailure: true, + CreatedBy: user.ID, + }, + history: &db.JobHistory{ + Status: "completed", + StartTime: time.Now().Add(-5 * time.Minute), + EndTime: timePtr(time.Now()), + }, + webhookEnabled: false, + webhookURL: mockServer.URL, + notifyOnSuccess: true, + notifyOnFailure: true, + status: "completed", + expectNotification: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Reset received data + webhookMutex.Lock() + receivedPayload = nil + receivedHeaders = nil + webhookCalled = false + waitCh = make(chan struct{}) + webhookMutex.Unlock() + + // Debug the test case configuration + t.Logf("Test configuration: name=%s, webhookEnabled=%v, notifyOnSuccess=%v, notifyOnFailure=%v, status=%s, expectNotification=%v", + tc.name, tc.webhookEnabled, tc.notifyOnSuccess, tc.notifyOnFailure, tc.status, tc.expectNotification) + + // Create a new job instance for each test case + job := &db.Job{ + Name: tc.job.Name, + ConfigID: tc.job.ConfigID, + WebhookEnabled: tc.webhookEnabled, + WebhookURL: tc.webhookURL, + NotifyOnSuccess: tc.notifyOnSuccess, + NotifyOnFailure: tc.notifyOnFailure, + CreatedBy: tc.job.CreatedBy, + } + + t.Logf("Job before DB create: WebhookEnabled=%v, NotifyOnSuccess=%v, NotifyOnFailure=%v", + job.WebhookEnabled, job.NotifyOnSuccess, job.NotifyOnFailure) + + err := database.DB.Create(job).Error + require.NoError(t, err) + + // Update the job to ensure the notification settings are correctly set + // This is necessary because the database has default values for these fields + err = database.DB.Model(job).Updates(map[string]interface{}{ + "notify_on_success": tc.notifyOnSuccess, + "notify_on_failure": tc.notifyOnFailure, + }).Error + require.NoError(t, err) + + // Reload the job to make sure we have the correct values + var reloadedJob db.Job + err = database.DB.First(&reloadedJob, job.ID).Error + require.NoError(t, err) + job = &reloadedJob + + t.Logf("Job after DB create: WebhookEnabled=%v, NotifyOnSuccess=%v, NotifyOnFailure=%v", + job.WebhookEnabled, job.NotifyOnSuccess, job.NotifyOnFailure) + + // Create and save job history + history := tc.history + history.JobID = job.ID + + err = database.DB.Create(history).Error + require.NoError(t, err) + + // Debug info + t.Logf("Test case: %s", tc.name) + t.Logf("Job settings: WebhookEnabled=%v, NotifyOnSuccess=%v, NotifyOnFailure=%v", + job.WebhookEnabled, job.NotifyOnSuccess, job.NotifyOnFailure) + t.Logf("History status: %s", history.Status) + + // Send webhook notification + scheduler.sendWebhookNotification(job, history, config) + + // Wait for webhook call to complete if expected + if tc.expectNotification { + // Wait with timeout for webhook to be called + select { + case <-waitCh: + // Webhook was called + case <-time.After(2 * time.Second): + t.Fatalf("Timed out waiting for webhook to be called") + } + } else { + // Give it a small window to ensure it doesn't call when not expected + time.Sleep(500 * time.Millisecond) + } + + // Check if notification was sent as expected + webhookMutex.Lock() + called := webhookCalled + payload := receivedPayload + headers := receivedHeaders + webhookMutex.Unlock() + + if tc.expectNotification { + assert.True(t, called, "Expected webhook notification to be sent") + require.NotNil(t, payload, "Expected webhook payload to be non-nil") + + // Verify the payload + var payloadMap map[string]interface{} + err := json.Unmarshal(payload, &payloadMap) + require.NoError(t, err, "Failed to unmarshal webhook payload") + + // Check common fields + assert.Equal(t, "job_execution", payloadMap["event_type"]) + assert.Equal(t, float64(job.ID), payloadMap["job_id"]) + assert.Equal(t, job.Name, payloadMap["job_name"]) + assert.Equal(t, float64(config.ID), payloadMap["config_id"]) + assert.Equal(t, config.Name, payloadMap["config_name"]) + assert.Equal(t, history.Status, payloadMap["status"]) + + // Check headers + assert.Equal(t, "application/json", headers.Get("Content-Type")) + assert.Equal(t, "GoMFT-Webhook/1.0", headers.Get("User-Agent")) + + // Additional checks for specific status + if history.Status == "failed" { + assert.Equal(t, history.ErrorMessage, payloadMap["error_message"]) + } + } else { + assert.False(t, called, "Expected no webhook notification to be sent") + } + + // Clean up + err = database.DB.Unscoped().Delete(history).Error + require.NoError(t, err) + err = database.DB.Unscoped().Delete(job).Error + require.NoError(t, err) + }) + } +} + +// TestWebhookAuthentication tests the webhook authentication functionality +func TestWebhookAuthentication(t *testing.T) { + // Set up a temporary data directory for logs + tempDir := t.TempDir() + + // Set DATA_DIR environment variable for the test + originalDataDir := os.Getenv("DATA_DIR") + t.Setenv("DATA_DIR", tempDir) + defer os.Setenv("DATA_DIR", originalDataDir) + + // Create a test database + database := setupTestDB(t) + + // Create a test user + user := &db.User{ + Email: "webhook-auth-test@example.com", + PasswordHash: "hashed_password", + IsAdmin: true, + } + err := database.CreateUser(user) + require.NoError(t, err) + + // Create a test transfer config + config := &db.TransferConfig{ + Name: "Webhook Auth Test Config", + SourceType: "local", + SourcePath: "/source", + DestinationType: "local", + DestinationPath: "/dest", + CreatedBy: user.ID, + } + err = database.DB.Create(config).Error + require.NoError(t, err) + + // Create a mock HTTP server to receive webhook notifications + var ( + receivedPayload []byte + receivedHeaders http.Header + waitCh = make(chan struct{}) + ) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = r.Header.Clone() + var err error + receivedPayload, err = io.ReadAll(r.Body) + if err != nil { + t.Logf("Error reading request body: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + close(waitCh) + })) + defer mockServer.Close() + + // Create a test scheduler + scheduler := New(database) + defer scheduler.Stop() + + // Set up job with webhook secret + secret := "test-webhook-secret" + job := &db.Job{ + Name: "Auth Test Job", + ConfigID: config.ID, + WebhookEnabled: true, + WebhookURL: mockServer.URL, + WebhookSecret: secret, + NotifyOnSuccess: true, + NotifyOnFailure: true, + CreatedBy: user.ID, + } + err = database.DB.Create(job).Error + require.NoError(t, err) + + // Create job history + history := &db.JobHistory{ + JobID: job.ID, + Status: "completed", + StartTime: time.Now().Add(-5 * time.Minute), + EndTime: timePtr(time.Now()), + BytesTransferred: 1024, + FilesTransferred: 2, + } + err = database.DB.Create(history).Error + require.NoError(t, err) + + // Send webhook notification + scheduler.sendWebhookNotification(job, history, config) + + // Wait for webhook to be called + select { + case <-waitCh: + // Webhook was called + case <-time.After(2 * time.Second): + t.Fatalf("Timed out waiting for webhook to be called") + } + + // Verify the signature + require.NotNil(t, receivedPayload, "Expected webhook notification to be sent") + + // Check that the X-Hub-Signature-256 header exists + signature := receivedHeaders.Get("X-Hub-Signature-256") + require.NotEmpty(t, signature, "Expected X-Hub-Signature-256 header to be set") + + // Verify that the signature matches the expected HMAC-SHA256 + h := hmac.New(sha256.New, []byte(secret)) + h.Write(receivedPayload) + expectedSignature := hex.EncodeToString(h.Sum(nil)) + + // Print both signatures for debugging if they don't match + if expectedSignature != signature { + t.Logf("Expected signature: %s", expectedSignature) + t.Logf("Actual signature: %s", signature) + t.Logf("Secret used: %s", secret) + t.Logf("Payload length: %d", len(receivedPayload)) + } + + assert.Equal(t, expectedSignature, signature, "Signature does not match expected value") + + // Clean up + err = database.DB.Unscoped().Delete(history).Error + require.NoError(t, err) + err = database.DB.Unscoped().Delete(job).Error + require.NoError(t, err) +} + +// TestWebhookCustomHeaders tests the custom headers functionality for webhooks +func TestWebhookCustomHeaders(t *testing.T) { + // Set up a temporary data directory for logs + tempDir := t.TempDir() + + // Set DATA_DIR environment variable for the test + originalDataDir := os.Getenv("DATA_DIR") + t.Setenv("DATA_DIR", tempDir) + defer os.Setenv("DATA_DIR", originalDataDir) + + // Create a test database + database := setupTestDB(t) + + // Create a test user + user := &db.User{ + Email: "webhook-headers-test@example.com", + PasswordHash: "hashed_password", + IsAdmin: true, + } + err := database.CreateUser(user) + require.NoError(t, err) + + // Create a test transfer config + config := &db.TransferConfig{ + Name: "Webhook Headers Test Config", + SourceType: "local", + SourcePath: "/source", + DestinationType: "local", + DestinationPath: "/dest", + CreatedBy: user.ID, + } + err = database.DB.Create(config).Error + require.NoError(t, err) + + // Create a mock HTTP server to receive webhook notifications + var ( + receivedPayload []byte + receivedHeaders http.Header + waitCh = make(chan struct{}) + ) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = r.Header.Clone() + var err error + receivedPayload, err = io.ReadAll(r.Body) + if err != nil { + t.Logf("Error reading request body: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + close(waitCh) + })) + defer mockServer.Close() + + // Create a test scheduler + scheduler := New(database) + defer scheduler.Stop() + + // Define custom headers + customHeaders := map[string]string{ + "X-API-Key": "test-api-key", + "X-Client-ID": "test-client-id", + "X-Source": "gomft-test", + } + + customHeadersJSON, err := json.Marshal(customHeaders) + require.NoError(t, err) + + // Set up job with custom headers + job := &db.Job{ + Name: "Custom Headers Test Job", + ConfigID: config.ID, + WebhookEnabled: true, + WebhookURL: mockServer.URL, + WebhookHeaders: string(customHeadersJSON), + NotifyOnSuccess: true, + NotifyOnFailure: true, + CreatedBy: user.ID, + } + err = database.DB.Create(job).Error + require.NoError(t, err) + + // Create job history + history := &db.JobHistory{ + JobID: job.ID, + Status: "completed", + StartTime: time.Now().Add(-5 * time.Minute), + EndTime: timePtr(time.Now()), + BytesTransferred: 1024, + FilesTransferred: 2, + } + err = database.DB.Create(history).Error + require.NoError(t, err) + + // Send webhook notification + scheduler.sendWebhookNotification(job, history, config) + + // Wait for webhook to be called + select { + case <-waitCh: + // Webhook was called + case <-time.After(2 * time.Second): + t.Fatalf("Timed out waiting for webhook to be called") + } + + // Verify the headers + require.NotNil(t, receivedPayload, "Expected webhook notification to be sent") + + // Check that all custom headers are present + for key, value := range customHeaders { + actualValue := receivedHeaders.Get(key) + if actualValue != value { + t.Logf("Custom header mismatch for %s: expected=%s, got=%s", key, value, actualValue) + } + assert.Equal(t, value, actualValue, "Expected custom header %s to be set", key) + } + + // Also check standard headers + assert.Equal(t, "application/json", receivedHeaders.Get("Content-Type")) + assert.Equal(t, "GoMFT-Webhook/1.0", receivedHeaders.Get("User-Agent")) + + // Clean up + err = database.DB.Unscoped().Delete(history).Error + require.NoError(t, err) + err = database.DB.Unscoped().Delete(job).Error + require.NoError(t, err) +} + +// Helper function to create a pointer to a time.Time value +func timePtr(t time.Time) *time.Time { + return &t +} diff --git a/internal/web/handlers/webhook_test.go b/internal/web/handlers/webhook_test.go new file mode 100644 index 0000000..0b012db --- /dev/null +++ b/internal/web/handlers/webhook_test.go @@ -0,0 +1,243 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "github.com/starfleetcptn/gomft/internal/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestWebhookConfiguration tests the webhook configuration during job creation and editing +func TestWebhookConfiguration(t *testing.T) { + // Set up test environment + handlers, router, database, user, config := setupJobsTest(t) + + // Add job create route + router.POST("/jobs/create", handlers.HandleCreateJob) + + // Create job form data with webhook enabled + formData := url.Values{ + "name": {"Webhook Test Job"}, + "config_ids[]": {strconv.Itoa(int(config.ID))}, + "schedule": {"*/15 * * * *"}, + "enabled": {"true"}, + "webhook_enabled": {"true"}, + "webhook_url": {"https://example.com/webhook"}, + "webhook_secret": {"test-secret"}, + "webhook_headers": {`{"X-Test-Header": "test-value"}`}, + "notify_on_success": {"true"}, + "notify_on_failure": {"true"}, + } + + // Submit form + req, _ := http.NewRequest("POST", "/jobs/create", strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + // Should redirect on success + assert.Equal(t, http.StatusFound, resp.Code) + + // Check if job was created with webhook settings + var jobs []db.Job + err := database.DB.Where("created_by = ?", user.ID).Find(&jobs).Error + require.NoError(t, err) + require.GreaterOrEqual(t, len(jobs), 1) + + // Get the most recently created job + var job db.Job + err = database.DB.Where("created_by = ?", user.ID).Order("created_at DESC").First(&job).Error + require.NoError(t, err) + + // Verify webhook settings were saved correctly + assert.True(t, job.WebhookEnabled) + assert.Equal(t, "https://example.com/webhook", job.WebhookURL) + assert.Equal(t, "test-secret", job.WebhookSecret) + assert.Equal(t, `{"X-Test-Header": "test-value"}`, job.WebhookHeaders) + assert.True(t, job.NotifyOnSuccess) + assert.True(t, job.NotifyOnFailure) +} + +// TestWebhookEditConfiguration tests editing webhook configuration +func TestWebhookEditConfiguration(t *testing.T) { + // Set up test environment + handlers, router, database, user, config := setupJobsTest(t) + + // Create a job first + job := &db.Job{ + Name: "Initial Job", + ConfigID: config.ID, + Schedule: "*/30 * * * *", + Enabled: true, + WebhookEnabled: false, // Initially disabled + CreatedBy: user.ID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + err := database.DB.Create(job).Error + require.NoError(t, err) + + // Add job update route + router.PUT("/jobs/:id", handlers.HandleUpdateJob) + + // Create edit form data to enable webhook + formData := url.Values{ + "name": {"Updated Job"}, + "config_ids[]": {strconv.Itoa(int(config.ID))}, + "schedule": {"*/30 * * * *"}, + "enabled": {"true"}, + "webhook_enabled": {"true"}, // Enabling webhook + "webhook_url": {"https://example.com/webhook"}, // Adding URL + "webhook_secret": {"new-secret"}, // Adding secret + "webhook_headers": {`{"X-Api-Key": "12345"}`}, // Adding headers + "notify_on_success": {"true"}, // Configure notifications + "notify_on_failure": {"false"}, // Only notify on success + } + + // Submit edit form + req, _ := http.NewRequest("PUT", "/jobs/"+strconv.Itoa(int(job.ID)), strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + // Should redirect on success + assert.Equal(t, http.StatusFound, resp.Code) + + // Get the updated job + var updatedJob db.Job + err = database.DB.First(&updatedJob, job.ID).Error + require.NoError(t, err) + + // Verify webhook settings were updated correctly + assert.True(t, updatedJob.WebhookEnabled) + assert.Equal(t, "https://example.com/webhook", updatedJob.WebhookURL) + assert.Equal(t, "new-secret", updatedJob.WebhookSecret) + assert.Equal(t, `{"X-Api-Key": "12345"}`, updatedJob.WebhookHeaders) + assert.True(t, updatedJob.NotifyOnSuccess) + assert.False(t, updatedJob.NotifyOnFailure) +} + +// TestDisablingWebhook tests disabling a previously enabled webhook +func TestDisablingWebhook(t *testing.T) { + // Set up test environment + handlers, router, database, user, config := setupJobsTest(t) + + // Create a job with webhook enabled + job := &db.Job{ + Name: "Webhook Enabled Job", + ConfigID: config.ID, + Schedule: "*/30 * * * *", + Enabled: true, + WebhookEnabled: true, + WebhookURL: "https://example.com/webhook", + WebhookSecret: "secret", + WebhookHeaders: `{"X-Test": "test"}`, + NotifyOnSuccess: true, + NotifyOnFailure: true, + CreatedBy: user.ID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + err := database.DB.Create(job).Error + require.NoError(t, err) + + // Add job update route + router.PUT("/jobs/:id", handlers.HandleUpdateJob) + + // Create edit form data to disable webhook + formData := url.Values{ + "name": {"Webhook Disabled Job"}, + "config_ids[]": {strconv.Itoa(int(config.ID))}, + "schedule": {"*/30 * * * *"}, + "enabled": {"true"}, + "webhook_enabled": {"false"}, // Explicitly set to false + "webhook_url": {"https://example.com/webhook"}, // URL remains the same + "webhook_secret": {"secret"}, // Secret remains the same + "webhook_headers": {`{"X-Test": "test"}`}, // Headers remain the same + } + + // Submit edit form + req, _ := http.NewRequest("PUT", "/jobs/"+strconv.Itoa(int(job.ID)), strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + // Should redirect on success + assert.Equal(t, http.StatusFound, resp.Code) + + // Get the updated job + var updatedJob db.Job + err = database.DB.First(&updatedJob, job.ID).Error + require.NoError(t, err) + + // Verify webhook was disabled + assert.False(t, updatedJob.WebhookEnabled) + + // Other fields should remain unchanged + assert.Equal(t, "https://example.com/webhook", updatedJob.WebhookURL) + assert.Equal(t, "secret", updatedJob.WebhookSecret) + assert.Equal(t, `{"X-Test": "test"}`, updatedJob.WebhookHeaders) +} + +// TestWebhookValidation tests validation of webhook URL +func TestWebhookValidation(t *testing.T) { + // Set up test environment + handlers, router, _, _, config := setupJobsTest(t) + + // Add job create route + router.POST("/jobs/create", handlers.HandleCreateJob) + + // Create job form data with invalid webhook URL + formData := url.Values{ + "name": {"Invalid Webhook Job"}, + "config_ids[]": {strconv.Itoa(int(config.ID))}, + "schedule": {"*/15 * * * *"}, + "enabled": {"true"}, + "webhook_enabled": {"true"}, + "webhook_url": {"invalid-url"}, // Invalid URL + "webhook_secret": {"test-secret"}, + "notify_on_success": {"true"}, + "notify_on_failure": {"true"}, + } + + // Submit form + req, _ := http.NewRequest("POST", "/jobs/create", strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + // Should not create job with invalid webhook URL + assert.NotEqual(t, http.StatusFound, resp.Code) + assert.Contains(t, resp.Body.String(), "valid URL") + + // Test invalid headers JSON + formData = url.Values{ + "name": {"Invalid Headers Job"}, + "config_ids[]": {strconv.Itoa(int(config.ID))}, + "schedule": {"*/15 * * * *"}, + "enabled": {"true"}, + "webhook_enabled": {"true"}, + "webhook_url": {"https://example.com/webhook"}, + "webhook_secret": {"test-secret"}, + "webhook_headers": {`{"invalid json`}, // Invalid JSON + "notify_on_success": {"true"}, + "notify_on_failure": {"true"}, + } + + // Submit form + req, _ = http.NewRequest("POST", "/jobs/create", strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp = httptest.NewRecorder() + router.ServeHTTP(resp, req) + + // Should not create job with invalid headers JSON + assert.NotEqual(t, http.StatusFound, resp.Code) + assert.Contains(t, resp.Body.String(), "valid JSON") +} diff --git a/screenshots/new.configuration.gomft.png b/screenshots/new.configuration.gomft.png index ccf70c7..38c7d99 100644 Binary files a/screenshots/new.configuration.gomft.png and b/screenshots/new.configuration.gomft.png differ diff --git a/screenshots/new.job.gomft.png b/screenshots/new.job.gomft.png index 1b6b28c..eb68f1b 100644 Binary files a/screenshots/new.job.gomft.png and b/screenshots/new.job.gomft.png differ