Files
BackupX/server/internal/service/audit_service_webhook_test.go
Wu Qing 5021fe665e 功能: v2.1 可观测性与流控 (#47)
* 功能: v2.1 可观测性与流控 — Prometheus + 节点带宽 + 审计 Webhook

核心能力:
- Prometheus /metrics 端点:11 类指标(任务/存储/节点/SLA/验证/恢复/复制)
- 节点级带宽限速生效:model.Node.BandwidthLimit 覆盖全局默认
- 审计日志 Webhook 外输:HMAC-SHA256 签名,配合 SIEM 合规留档

实现:
- server/internal/metrics/  独立 Registry + 异步 Gauge Collector(30s)
- backup/restore/verify/replication 服务注入 metrics 钩子,nil 安全
- resolveProviderForNode() 按 task.NodeID 解析 BandwidthLimit
- AuditService.SetWebhook + 动态 settings 推送,无需重启

测试:
- metrics/registry_test.go: 注册/采集/nil safety/HTTP handler
- service/audit_service_webhook_test.go: 签名正确性/异步投递/禁用路径
- go test ./... 全部通过

* chore: 触发 CodeQL 扫描
2026-04-20 23:26:04 +08:00

130 lines
3.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"backupx/server/internal/model"
"backupx/server/internal/repository"
)
// fakeAuditRepo 用通道同步等待异步写入,避免 sleep。
type fakeAuditRepo struct {
mu sync.Mutex
logs []model.AuditLog
created chan struct{}
}
func newFakeAuditRepo() *fakeAuditRepo {
return &fakeAuditRepo{created: make(chan struct{}, 4)}
}
func (r *fakeAuditRepo) Create(_ context.Context, log *model.AuditLog) error {
r.mu.Lock()
log.CreatedAt = time.Now().UTC()
r.logs = append(r.logs, *log)
r.mu.Unlock()
r.created <- struct{}{}
return nil
}
func (r *fakeAuditRepo) List(context.Context, repository.AuditLogListOptions) (*repository.AuditLogListResult, error) {
return &repository.AuditLogListResult{}, nil
}
func (r *fakeAuditRepo) ListAll(context.Context, repository.AuditLogListOptions) ([]model.AuditLog, error) {
return nil, nil
}
func TestAuditService_WebhookDeliversSignedPayload(t *testing.T) {
var hits atomic.Int32
var got struct {
sig string
payload map[string]any
received chan struct{}
}
got.received = make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
body, _ := io.ReadAll(r.Body)
got.sig = r.Header.Get("X-BackupX-Signature")
_ = json.Unmarshal(body, &got.payload)
// 验证 HMAC 正确
mac := hmac.New(sha256.New, []byte("s3cret"))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if got.sig != expected {
t.Errorf("signature mismatch: expected %s, got %s", expected, got.sig)
}
w.WriteHeader(http.StatusOK)
got.received <- struct{}{}
}))
defer server.Close()
repo := newFakeAuditRepo()
svc := NewAuditService(repo)
svc.SetWebhook(server.URL, "s3cret")
svc.Record(AuditEntry{
Username: "alice",
Category: "auth",
Action: "login_success",
ClientIP: "10.0.0.1",
Detail: "admin login",
})
// 等待异步写入 + webhook
select {
case <-repo.created:
case <-time.After(time.Second):
t.Fatal("audit log not written within 1s")
}
select {
case <-got.received:
case <-time.After(time.Second):
t.Fatal("webhook not invoked within 1s")
}
if hits.Load() != 1 {
t.Fatalf("expected 1 webhook hit, got %d", hits.Load())
}
if got.payload["eventType"] != "audit.log" {
t.Errorf("eventType wrong: %v", got.payload["eventType"])
}
actor, ok := got.payload["actor"].(map[string]any)
if !ok || actor["username"] != "alice" {
t.Errorf("actor.username mismatch: %v", got.payload["actor"])
}
if got.payload["action"] != "login_success" {
t.Errorf("action mismatch: %v", got.payload["action"])
}
}
func TestAuditService_WebhookDisabledWhenURLEmpty(t *testing.T) {
repo := newFakeAuditRepo()
svc := NewAuditService(repo)
// 不调用 SetWebhook应该不发送任何请求
svc.Record(AuditEntry{Username: "bob", Action: "logout"})
select {
case <-repo.created:
case <-time.After(time.Second):
t.Fatal("audit log not written within 1s")
}
// 给 webhook 一些时间(即便它不会被调用)
time.Sleep(100 * time.Millisecond)
// 无显式断言:能不 panic 即算通过
}