From d5b0a686e09cb6098740174b5a44f1b406dbca36 Mon Sep 17 00:00:00 2001
From: StarFleetCPTN
Date: Fri, 14 Mar 2025 18:22:59 -0700
Subject: [PATCH] feat: Implement webhook notifications and admin tools for job
management
- Add webhook notification settings to job configuration, allowing users to enable notifications for job success and failure.
- Implement webhook payload structure and authentication using HMAC-SHA256 for secure communication.
- Enhance the admin tools with a log viewer and system management features, including database backup and log file access.
- Update README documentation to include details on webhook integration and admin tools.
- Introduce comprehensive tests for webhook functionality, ensuring correct payload delivery and header validation.
- Add database migrations to support new webhook fields in job configurations.
---
README.md | 117 +++-
components/admin_tools.templ | 83 +++
components/job_form.templ | 236 +++++++
components/providers/providers_test.go | 12 -
internal/db/db.go | 15 +-
internal/db/migrations/add_webhook_support.go | 61 ++
internal/db/migrations/migrations.go | 1 +
internal/scheduler/scheduler.go | 125 ++++
internal/scheduler/scheduler_test.go | 7 +-
.../scheduler/webhook_integration_test.go | 567 ++++++++++++++++
internal/scheduler/webhook_test.go | 610 ++++++++++++++++++
internal/web/handlers/webhook_test.go | 243 +++++++
screenshots/new.configuration.gomft.png | Bin 674891 -> 844727 bytes
screenshots/new.job.gomft.png | Bin 360448 -> 643952 bytes
14 files changed, 2058 insertions(+), 19 deletions(-)
create mode 100644 internal/db/migrations/add_webhook_support.go
create mode 100644 internal/scheduler/webhook_integration_test.go
create mode 100644 internal/scheduler/webhook_test.go
create mode 100644 internal/web/handlers/webhook_test.go
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

*Create user accounts and manage them*
+### Admin Tools
+
+*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:
+
+ 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
+
+
+
+
+
Property
+
Value
+
+
+
+
+
Method
+
POST
+
+
+
Content-Type
+
application/json
+
+
+
User-Agent
+
GoMFT-Webhook/1.0
+
+
+
X-Hub-Signature-256
+
HMAC SHA256 signature (if secret configured)
+
+
+
Custom Headers
+
Any 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 ccf70c78613821b9b2ac1b1b05a24aefb847d0a3..38c7d992f9234053d84b530d7e7199ef2a9d179f 100644
GIT binary patch
literal 844727
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