Files
tokens-reef/backend/internal/service/webhook_service_test.go
User eb5d32553d
Some checks failed
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
feat: add webhook notification service and refactor data management
## 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
2026-04-15 23:03:48 +08:00

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)
})
}