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