346 lines
12 KiB
Go
346 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"oci-portal/internal/crypto"
|
|
"oci-portal/internal/model"
|
|
)
|
|
|
|
func TestValidateAlertRule(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
rule model.AlertRule
|
|
wantErr string
|
|
check func(t *testing.T, r model.AlertRule)
|
|
}{
|
|
{name: "名称必填", rule: model.AlertRule{Threshold: 1}, wantErr: "名称"},
|
|
{name: "模式非法", rule: model.AlertRule{Name: "r", Threshold: 1, SourceIPMode: "any"}, wantErr: "in/notin"},
|
|
{name: "阈值越界", rule: model.AlertRule{Name: "r", Threshold: 101}, wantErr: "阈值"},
|
|
{name: "阈值>1须带窗口", rule: model.AlertRule{Name: "r", Threshold: 3}, wantErr: "窗口"},
|
|
{name: "IP 非法", rule: model.AlertRule{Name: "r", Threshold: 1, SourceIPs: "300.1.1.1"}, wantErr: "IP"},
|
|
{name: "CIDR 合法", rule: model.AlertRule{Name: "r", Threshold: 1, SourceIPs: "10.0.0.0/8, 1.2.3.4"},
|
|
check: func(t *testing.T, r model.AlertRule) {
|
|
if r.SourceIPs != "10.0.0.0/8,1.2.3.4" {
|
|
t.Errorf("SourceIPs = %q, 应去空白归一化", r.SourceIPs)
|
|
}
|
|
if r.SourceIPMode != alertSourceIPIn {
|
|
t.Errorf("SourceIPMode = %q, 应默认 in", r.SourceIPMode)
|
|
}
|
|
}},
|
|
{name: "事件清单归一化", rule: model.AlertRule{Name: "r", Threshold: 1, EventTypes: " TerminateInstance , CreateApiKey ,"},
|
|
check: func(t *testing.T, r model.AlertRule) {
|
|
if r.EventTypes != "TerminateInstance,CreateApiKey" {
|
|
t.Errorf("EventTypes = %q", r.EventTypes)
|
|
}
|
|
}},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rule := tt.rule
|
|
err := validateAlertRule(&rule)
|
|
if tt.wantErr == "" {
|
|
if err != nil {
|
|
t.Fatalf("validateAlertRule: %v", err)
|
|
}
|
|
if tt.check != nil {
|
|
tt.check(t, rule)
|
|
}
|
|
return
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Fatalf("err = %v, want contains %q", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRuleHits(t *testing.T) {
|
|
base := model.AlertRule{Name: "r", Threshold: 1, SourceIPMode: alertSourceIPIn}
|
|
ev := &model.LogEvent{OciConfigID: 7}
|
|
parsed := parsedEvent{
|
|
EventType: "com.oraclecloud.ComputeApi.TerminateInstance",
|
|
SourceIP: "203.0.113.8",
|
|
ResourceName: "web-server-1",
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
mod func(r *model.AlertRule)
|
|
p *parsedEvent
|
|
want bool
|
|
}{
|
|
{name: "空条件全命中", mod: func(r *model.AlertRule) {}, want: true},
|
|
{name: "租户匹配", mod: func(r *model.AlertRule) { r.OciConfigID = 7 }, want: true},
|
|
{name: "租户不匹配", mod: func(r *model.AlertRule) { r.OciConfigID = 8 }, want: false},
|
|
{name: "事件短名命中", mod: func(r *model.AlertRule) { r.EventTypes = "LaunchInstance,TerminateInstance" }, want: true},
|
|
{name: "事件不在清单", mod: func(r *model.AlertRule) { r.EventTypes = "CreateUser" }, want: false},
|
|
{name: "资源子串命中", mod: func(r *model.AlertRule) { r.ResourceMatch = "web-" }, want: true},
|
|
{name: "资源不含", mod: func(r *model.AlertRule) { r.ResourceMatch = "db-" }, want: false},
|
|
{name: "IP in 命中 CIDR", mod: func(r *model.AlertRule) { r.SourceIPs = "203.0.113.0/24" }, want: true},
|
|
{name: "IP in 未命中", mod: func(r *model.AlertRule) { r.SourceIPs = "10.0.0.0/8" }, want: false},
|
|
{name: "IP notin 白名单外告警", mod: func(r *model.AlertRule) {
|
|
r.SourceIPs, r.SourceIPMode = "10.0.0.0/8", alertSourceIPNotIn
|
|
}, want: true},
|
|
{name: "IP notin 白名单内不告警", mod: func(r *model.AlertRule) {
|
|
r.SourceIPs, r.SourceIPMode = "203.0.113.8", alertSourceIPNotIn
|
|
}, want: false},
|
|
{name: "notin 事件缺 IP 不告警", mod: func(r *model.AlertRule) {
|
|
r.SourceIPs, r.SourceIPMode = "10.0.0.0/8", alertSourceIPNotIn
|
|
}, p: &parsedEvent{EventType: parsed.EventType}, want: false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rule := base
|
|
tt.mod(&rule)
|
|
p := parsed
|
|
if tt.p != nil {
|
|
p = *tt.p
|
|
}
|
|
if got := ruleHits(rule, ev, p); got != tt.want {
|
|
t.Errorf("ruleHits = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAlertRuleCRUD(t *testing.T) {
|
|
svc, _, _ := newLogEventEnv(t)
|
|
ctx := context.Background()
|
|
|
|
created, err := svc.CreateAlertRule(ctx, model.AlertRule{Name: "非白名单终止", Enabled: true, Threshold: 1})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if created.ID == 0 {
|
|
t.Fatal("create 未回填 ID")
|
|
}
|
|
if _, err := svc.CreateAlertRule(ctx, model.AlertRule{Threshold: 1}); err == nil {
|
|
t.Fatal("空名称应校验失败")
|
|
}
|
|
|
|
created.Enabled = false
|
|
created.EventTypes = "TerminateInstance"
|
|
updated, err := svc.UpdateAlertRule(ctx, created.ID, created)
|
|
if err != nil {
|
|
t.Fatalf("update: %v", err)
|
|
}
|
|
if updated.Enabled || updated.EventTypes != "TerminateInstance" {
|
|
t.Fatalf("update 未生效: %+v", updated)
|
|
}
|
|
|
|
rules, err := svc.ListAlertRules(ctx)
|
|
if err != nil || len(rules) != 1 {
|
|
t.Fatalf("list = %v, %v", rules, err)
|
|
}
|
|
if err := svc.DeleteAlertRule(ctx, created.ID); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
if rules, _ := svc.ListAlertRules(ctx); len(rules) != 0 {
|
|
t.Fatalf("delete 后仍有 %d 条", len(rules))
|
|
}
|
|
}
|
|
|
|
// auditEventPayload 构造一条含资源与来源 IP 的 CloudEvents 审计消息。
|
|
func auditEventPayload(event, resource, ip string) string {
|
|
return fmt.Sprintf(`{"eventType":"com.oraclecloud.ComputeApi.%s","source":"ComputeApi",`+
|
|
`"eventTime":"2026-07-10T08:00:00Z","data":{"resourceName":%q,"identity":{"ipAddress":%q}}}`,
|
|
event, resource, ip)
|
|
}
|
|
|
|
// newAlertNotifyEnv 组装带假 Telegram 通道的告警测试环境。
|
|
func newAlertNotifyEnv(t *testing.T) (*LogEventService, *telegramCapture, func()) {
|
|
t.Helper()
|
|
svc, db, _ := newLogEventEnv(t)
|
|
srv, rec := newFakeTelegram(t, `{"ok":true}`)
|
|
cipher, err := crypto.NewCipher("test-data-key")
|
|
if err != nil {
|
|
t.Fatalf("new cipher: %v", err)
|
|
}
|
|
settings := NewSettingService(db, cipher)
|
|
token := "123456:AAfake"
|
|
if err := settings.UpdateTelegram(context.Background(),
|
|
UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"}); err != nil {
|
|
t.Fatalf("update telegram: %v", err)
|
|
}
|
|
n := NewNotifier(settings)
|
|
n.base = srv.URL
|
|
svc.SetNotifier(n, settings)
|
|
return svc, rec, n.Wait
|
|
}
|
|
|
|
func TestMatchAlertRulesNotify(t *testing.T) {
|
|
svc, rec, wait := newAlertNotifyEnv(t)
|
|
ctx := context.Background()
|
|
_, err := svc.CreateAlertRule(ctx, model.AlertRule{
|
|
Name: "白名单外终止", Enabled: true, Threshold: 1,
|
|
EventTypes: "TerminateInstance", SourceIPs: "10.0.0.0/8", SourceIPMode: alertSourceIPNotIn,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create rule: %v", err)
|
|
}
|
|
// 命中:白名单外 IP;不命中:白名单内 IP
|
|
mustIngest(t, svc, "m1", auditEventPayload("TerminateInstance", "web-1", "203.0.113.8"))
|
|
mustIngest(t, svc, "m2", auditEventPayload("TerminateInstance", "web-2", "10.1.2.3"))
|
|
svc.parseOnce(ctx)
|
|
wait()
|
|
|
|
alerts := auditAlerts(rec.snapshot())
|
|
joined := strings.Join(alerts, "\n---\n")
|
|
if !strings.Contains(joined, "白名单外终止") || !strings.Contains(joined, "web-1") {
|
|
t.Fatalf("应收到含规则名与资源的告警,got %q", joined)
|
|
}
|
|
if strings.Contains(joined, "web-2") {
|
|
t.Fatalf("白名单内事件不应告警,got %q", joined)
|
|
}
|
|
}
|
|
|
|
// auditAlerts 过滤出审计告警推送(排除既有 notifyCritical 的云端事件通知)。
|
|
func auditAlerts(texts []string) []string {
|
|
var out []string
|
|
for _, s := range texts {
|
|
if strings.Contains(s, "审计告警") {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestAlertThresholdWindow(t *testing.T) {
|
|
svc, rec, wait := newAlertNotifyEnv(t)
|
|
ctx := context.Background()
|
|
_, err := svc.CreateAlertRule(ctx, model.AlertRule{
|
|
Name: "登录风暴", Enabled: true, Threshold: 3, WindowMinutes: 5, EventTypes: "InteractiveLogin",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create rule: %v", err)
|
|
}
|
|
for i := 1; i <= 4; i++ {
|
|
mustIngest(t, svc, fmt.Sprint("login-", i),
|
|
auditEventPayload("InteractiveLogin", "user@x.com", "203.0.113.8"))
|
|
}
|
|
svc.parseOnce(ctx)
|
|
wait()
|
|
|
|
alerts := auditAlerts(rec.snapshot())
|
|
if len(alerts) != 1 {
|
|
t.Fatalf("窗口内 4 次命中应只告警 1 次(第 3 次触发后冷却),got %d 条: %v", len(alerts), alerts)
|
|
}
|
|
if !strings.Contains(alerts[0], "3 次") {
|
|
t.Errorf("告警文案应含累计次数,got %q", alerts[0])
|
|
}
|
|
}
|
|
|
|
// TestAlertRuleBadDataDoesNotBlockParse 验证规则表异常不影响解析主流程。
|
|
func TestAlertRuleBadDataDoesNotBlockParse(t *testing.T) {
|
|
svc, db, _ := newLogEventEnv(t)
|
|
ctx := context.Background()
|
|
// 直插一条绕过校验的坏规则(IP 列表非法)
|
|
bad := model.AlertRule{Name: "bad", Enabled: true, Threshold: 1, SourceIPs: "not-an-ip"}
|
|
if err := db.Create(&bad).Error; err != nil {
|
|
t.Fatalf("insert bad rule: %v", err)
|
|
}
|
|
mustIngest(t, svc, "m1", auditEventPayload("TerminateInstance", "web-1", "1.2.3.4"))
|
|
svc.parseOnce(ctx)
|
|
|
|
var e model.LogEvent
|
|
if err := db.First(&e, "message_id = ?", "m1").Error; err != nil {
|
|
t.Fatalf("find event: %v", err)
|
|
}
|
|
if !e.Processed {
|
|
t.Fatal("坏规则不应阻塞事件解析")
|
|
}
|
|
}
|
|
|
|
// mustIngest 落一条回传事件,失败即终止测试。
|
|
func mustIngest(t *testing.T, svc *LogEventService, msgID, payload string) {
|
|
t.Helper()
|
|
if err := svc.Ingest(context.Background(), 1, msgID, []byte(payload), false); err != nil {
|
|
t.Fatalf("ingest %s: %v", msgID, err)
|
|
}
|
|
}
|
|
|
|
// TestCleanupAlertHits 验证过期命中记录随清理删除。
|
|
func TestCleanupAlertHits(t *testing.T) {
|
|
svc, db, _ := newLogEventEnv(t)
|
|
old := model.AlertRuleHit{RuleID: 1, HitAt: time.Now().Add(-8 * 24 * time.Hour)}
|
|
fresh := model.AlertRuleHit{RuleID: 1, HitAt: time.Now()}
|
|
if err := db.Create(&old).Error; err != nil {
|
|
t.Fatalf("insert: %v", err)
|
|
}
|
|
if err := db.Create(&fresh).Error; err != nil {
|
|
t.Fatalf("insert: %v", err)
|
|
}
|
|
svc.cleanupAlertHits(context.Background())
|
|
var count int64
|
|
db.Model(&model.AlertRuleHit{}).Count(&count)
|
|
if count != 1 {
|
|
t.Fatalf("清理后应剩 1 条,got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestClearAlertCooldown(t *testing.T) {
|
|
svc := NewLogEventService(nil)
|
|
rule1 := model.AlertRule{ID: 1, Threshold: 2, WindowMinutes: 10}
|
|
rule2 := model.AlertRule{ID: 2, Threshold: 2, WindowMinutes: 10}
|
|
if !svc.alertCooldownPass(rule1) || !svc.alertCooldownPass(rule2) {
|
|
t.Fatal("首次命中应通过冷却检查")
|
|
}
|
|
|
|
svc.ClearAlertCooldown([]uint{rule1.ID})
|
|
if !svc.alertCooldownPass(rule1) {
|
|
t.Fatal("已清理规则应重新通过冷却检查")
|
|
}
|
|
if svc.alertCooldownPass(rule2) {
|
|
t.Fatal("未清理规则不应通过冷却检查")
|
|
}
|
|
}
|
|
|
|
func TestRecordAlertHitRejectsMissingRefs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
deleteRule bool
|
|
}{
|
|
{name: "规则已删除", deleteRule: true},
|
|
{name: "事件已删除"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
testRecordAlertHitMissingRef(t, tt.deleteRule)
|
|
})
|
|
}
|
|
}
|
|
|
|
func testRecordAlertHitMissingRef(t *testing.T, deleteRule bool) {
|
|
t.Helper()
|
|
svc, db, cfgID := newLogEventEnv(t)
|
|
rule := model.AlertRule{Name: "r", OciConfigID: cfgID, Threshold: 2, WindowMinutes: 5}
|
|
event := model.LogEvent{OciConfigID: cfgID, MessageID: "m"}
|
|
if err := db.Create(&rule).Error; err != nil {
|
|
t.Fatalf("create rule: %v", err)
|
|
}
|
|
if err := db.Create(&event).Error; err != nil {
|
|
t.Fatalf("create event: %v", err)
|
|
}
|
|
var err error
|
|
if deleteRule {
|
|
err = db.Delete(&rule).Error
|
|
} else {
|
|
err = db.Delete(&event).Error
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("delete reference: %v", err)
|
|
}
|
|
if count, ok := svc.recordAlertHit(context.Background(), rule, &event); ok || count != 0 {
|
|
t.Fatalf("record missing refs = (%d,%v), want (0,false)", count, ok)
|
|
}
|
|
var hits int64
|
|
db.Model(&model.AlertRuleHit{}).Count(&hits)
|
|
if hits != 0 {
|
|
t.Fatalf("orphan alert hits = %d, want 0", hits)
|
|
}
|
|
}
|