## Backend Changes - Add WebhookService for sending alert notifications via HTTP webhooks - Implement HMAC-SHA256 signature for webhook payload authentication - Add webhook configuration API endpoints and settings - Integrate webhook calls into OpsAlertEvaluatorService - Fix routes/common.go string conversion (use strconv.Itoa) - Add comprehensive webhook service tests ## Frontend Changes - Add webhook notification configuration UI in OpsSettingsDialog - Add WebhookNotificationConfig types and API functions - Add i18n translations for webhook features (zh/en) - Refactor DataManagementView.vue into modular components: - PostgresProfilesCard.vue (356 lines) - RedisProfilesCard.vue (331 lines) - S3ProfilesCard.vue (363 lines) - BackupJobsCard.vue (216 lines) - DataManagementView.vue (94 lines) - Add OpsSettingsDialog component tests ## Testing - All backend tests pass - All frontend tests pass - Webhook service tests cover signature, HTTP, timeout, error handling
222 lines
6.4 KiB
Go
222 lines
6.4 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestShouldSendWebhookByMinSeverity(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
minSeverity string
|
|
ruleSeverity string
|
|
want bool
|
|
}{
|
|
{"critical >= critical", "critical", "critical", true},
|
|
{"warning >= critical", "critical", "warning", false},
|
|
{"critical >= warning", "warning", "critical", true},
|
|
{"warning >= warning", "warning", "warning", true},
|
|
{"info >= warning", "warning", "info", false},
|
|
{"info >= info", "info", "info", true},
|
|
{"empty min sends all", "", "info", true},
|
|
{"unknown severity sends by default", "unknown", "info", true},
|
|
{"case insensitive", "CRITICAL", "Warning", false},
|
|
{"case insensitive 2", "critical", "WARNING", false},
|
|
{"spaces trimmed", " critical ", " warning ", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := shouldSendWebhookByMinSeverity(tt.minSeverity, tt.ruleSeverity)
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWebhookService_BuildAlertPayload(t *testing.T) {
|
|
svc := &WebhookService{}
|
|
|
|
rule := &OpsAlertRule{
|
|
ID: 1,
|
|
Name: "Test Rule",
|
|
Severity: "critical",
|
|
}
|
|
|
|
event := &OpsAlertEvent{
|
|
ID: 1,
|
|
RuleID: 1,
|
|
Title: "Test Alert",
|
|
Description: "Test alert description",
|
|
Severity: "critical",
|
|
Status: "firing",
|
|
}
|
|
|
|
t.Run("alert payload", func(t *testing.T) {
|
|
payload := svc.buildAlertPayload(rule, event, false)
|
|
assert.Equal(t, "alert", payload.Type)
|
|
assert.NotEmpty(t, payload.Timestamp)
|
|
assert.Equal(t, rule, payload.Data.Rule)
|
|
assert.Equal(t, event, payload.Data.Event)
|
|
assert.Empty(t, payload.Data.ResolvedAt)
|
|
})
|
|
|
|
t.Run("resolved payload", func(t *testing.T) {
|
|
payload := svc.buildAlertPayload(rule, event, true)
|
|
assert.Equal(t, "alert_resolved", payload.Type)
|
|
assert.NotEmpty(t, payload.Data.ResolvedAt)
|
|
})
|
|
}
|
|
|
|
func TestWebhookService_SignPayload(t *testing.T) {
|
|
svc := &WebhookService{}
|
|
|
|
t.Run("empty secret does not sign", func(t *testing.T) {
|
|
payload := &OpsWebhookPayload{
|
|
Type: "alert",
|
|
Timestamp: "2024-01-01T00:00:00Z",
|
|
}
|
|
err := svc.signPayload(payload, "")
|
|
assert.NoError(t, err)
|
|
assert.Empty(t, payload.Signature)
|
|
})
|
|
|
|
t.Run("nil payload returns nil", func(t *testing.T) {
|
|
err := svc.signPayload(nil, "secret")
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("signs payload with HMAC-SHA256", func(t *testing.T) {
|
|
payload := &OpsWebhookPayload{
|
|
Type: "alert",
|
|
Timestamp: "2024-01-01T00:00:00Z",
|
|
Data: OpsWebhookData{
|
|
Rule: &OpsAlertRule{ID: 1},
|
|
},
|
|
}
|
|
secret := "test-secret"
|
|
|
|
err := svc.signPayload(payload, secret)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, payload.Signature)
|
|
assert.Len(t, payload.Signature, 64) // SHA256 hex encoding = 64 chars
|
|
|
|
// Verify signature
|
|
signPayload := &OpsWebhookPayload{
|
|
Type: payload.Type,
|
|
Timestamp: payload.Timestamp,
|
|
Data: payload.Data,
|
|
}
|
|
data, _ := json.Marshal(signPayload)
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write(data)
|
|
expectedSig := hex.EncodeToString(mac.Sum(nil))
|
|
assert.Equal(t, expectedSig, payload.Signature)
|
|
})
|
|
}
|
|
|
|
func TestWebhookService_SendWebhook(t *testing.T) {
|
|
svc := &WebhookService{
|
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
|
|
t.Run("successful webhook", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, http.MethodPost, r.Method)
|
|
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
|
assert.Contains(t, r.Header.Get("User-Agent"), "Sub2API-Webhook")
|
|
|
|
body, _ := io.ReadAll(r.Body)
|
|
assert.Contains(t, string(body), "test")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
payload := []byte(`{"type":"alert","test":true}`)
|
|
err := svc.sendWebhook(context.Background(), server.URL, payload, 5, "")
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("webhook with signature header", func(t *testing.T) {
|
|
secret := "test-secret"
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sigHeader := r.Header.Get("X-Webhook-Signature")
|
|
assert.NotEmpty(t, sigHeader)
|
|
assert.True(t, strings.HasPrefix(sigHeader, "sha256="))
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
payload := []byte(`{"type":"alert"}`)
|
|
err := svc.sendWebhook(context.Background(), server.URL, payload, 5, secret)
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("webhook returns error status", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("internal error"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
payload := []byte(`{"type":"alert"}`)
|
|
err := svc.sendWebhook(context.Background(), server.URL, payload, 5, "")
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "500")
|
|
})
|
|
|
|
t.Run("webhook timeout", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(2 * time.Second)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
payload := []byte(`{"type":"alert"}`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
err := svc.sendWebhook(ctx, server.URL, payload, 1, "")
|
|
assert.Error(t, err)
|
|
})
|
|
|
|
t.Run("invalid URL", func(t *testing.T) {
|
|
payload := []byte(`{"type":"alert"}`)
|
|
err := svc.sendWebhook(context.Background(), "://invalid-url", payload, 5, "")
|
|
assert.Error(t, err)
|
|
})
|
|
|
|
t.Run("nil http client returns error", func(t *testing.T) {
|
|
svc := &WebhookService{httpClient: nil}
|
|
payload := []byte(`{"type":"alert"}`)
|
|
err := svc.sendWebhook(context.Background(), "http://example.com", payload, 5, "")
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "not initialized")
|
|
})
|
|
}
|
|
|
|
func TestWebhookService_SendAlertWebhook_NilChecks(t *testing.T) {
|
|
t.Run("nil service returns nil", func(t *testing.T) {
|
|
var svc *WebhookService
|
|
err := svc.SendAlertWebhook(context.Background(), &OpsAlertRule{}, &OpsAlertEvent{}, false)
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("nil ops service returns nil", func(t *testing.T) {
|
|
svc := &WebhookService{opsService: nil}
|
|
err := svc.SendAlertWebhook(context.Background(), &OpsAlertRule{}, &OpsAlertEvent{}, false)
|
|
assert.NoError(t, err)
|
|
})
|
|
}
|