package model import ( "slices" "testing" ) type arSt struct { msg string rule *AlertRule points [][]bool expD int exp bool } func TestAlertRules(t *testing.T) { t.Run("CycleRules", testCycleRules) t.Run("OfflineRules", testOfflineRules) t.Run("GeneralRules", testGeneralRules) t.Run("CombinedRules", testCombinedRules) } func testCycleRules(t *testing.T) { cases := []arSt{ { rule: &AlertRule{ Rules: []*Rule{ { Type: "_cycle", }, }, }, msg: "CyclePass", points: [][]bool{{false}, {true}}, expD: 1, exp: true, }, { rule: &AlertRule{ Rules: []*Rule{ { Type: "_cycle", }, }, }, msg: "CycleFail", points: [][]bool{{true}, {false}}, expD: 1, exp: false, }, } for _, c := range cases { d, passed := c.rule.Check(c.points) assertEq(t, c.msg, c.expD, d) assertEq(t, c.msg, c.exp, passed) } } func testOfflineRules(t *testing.T) { cases := []arSt{ { rule: &AlertRule{ Rules: []*Rule{ { Type: "offline", Duration: 10, }, }, }, msg: "OfflineLast", points: append([][]bool{{true}}, repeat([]bool{false}, 9)...), expD: 10, exp: true, }, { rule: &AlertRule{ Rules: []*Rule{ { Type: "offline", Duration: 10, }, }, }, msg: "OfflineMiddle", points: mod(repeat([]bool{false}, 10), true, 5), expD: 5, exp: true, }, { rule: &AlertRule{ Rules: []*Rule{ { Type: "offline", Duration: 10, }, }, }, msg: "OfflineFirst", points: mod(repeat([]bool{false}, 10), true, 9), expD: 1, exp: true, }, { rule: &AlertRule{ Rules: []*Rule{ { Type: "offline", Duration: 10, }, }, }, msg: "OfflineBoundCheck", points: repeat([]bool{false}, 9), expD: 0, exp: true, }, } for _, c := range cases { d, passed := c.rule.Check(c.points) assertEq(t, c.msg, c.expD, d) assertEq(t, c.msg, c.exp, passed) } } func testGeneralRules(t *testing.T) { cases := []arSt{ { rule: &AlertRule{ Rules: []*Rule{ { Duration: 10, }, }, }, msg: "GeneralFail", points: repeat([]bool{false}, 10), expD: 10, exp: false, }, { rule: &AlertRule{ Rules: []*Rule{ { Duration: 10, }, }, }, msg: "GeneralFail80%", points: slices.Concat(repeat([]bool{false}, 8), repeat([]bool{true}, 2)), expD: 10, exp: false, }, { rule: &AlertRule{ Rules: []*Rule{ { Duration: 10, }, }, }, msg: "GeneralPass30%", points: slices.Concat(repeat([]bool{false}, 7), repeat([]bool{true}, 3)), expD: 10, exp: true, }, { rule: &AlertRule{ Rules: []*Rule{ { Duration: 10, }, }, }, msg: "GeneralPass", points: slices.Concat(repeat([]bool{false}, 4), repeat([]bool{true}, 6)), expD: 10, exp: true, }, { rule: &AlertRule{ Rules: []*Rule{ { Duration: 10, }, }, }, msg: "GeneralBoundCheck", points: repeat([]bool{false}, 9), expD: 0, exp: true, }, } for _, c := range cases { d, passed := c.rule.Check(c.points) assertEq(t, c.msg, c.expD, d) assertEq(t, c.msg, c.exp, passed) } } func testCombinedRules(t *testing.T) { cases := []arSt{ { rule: &AlertRule{ Rules: []*Rule{ { Type: "offline", Duration: 10, }, { Duration: 10, }, }, }, msg: "OfflineGeneralOfflinePass", points: slices.Concat(repeat([]bool{false, true}, 2), repeat([]bool{true, false}, 8)), expD: 1, exp: true, }, { rule: &AlertRule{ Rules: []*Rule{ { Type: "offline", Duration: 10, }, { Duration: 10, }, }, }, msg: "OfflineGeneralOfflineFail", points: slices.Concat(repeat([]bool{false, false}, 2), repeat([]bool{false, true}, 8)), expD: 10, exp: true, }, { rule: &AlertRule{ Rules: []*Rule{ { Duration: 10, }, { Type: "offline", Duration: 10, }, }, }, msg: "GeneralOffline", points: slices.Concat(repeat([]bool{false, true}, 2), repeat([]bool{true, false}, 8)), expD: 10, exp: true, }, { rule: &AlertRule{ Rules: []*Rule{ { Duration: 10, }, { Duration: 30, }, }, }, msg: "GeneralGeneral", points: slices.Concat(repeat([]bool{false, true}, 2), repeat([]bool{false, false}, 28)), expD: 30, exp: false, }, { rule: &AlertRule{ Rules: []*Rule{ { Duration: 10, }, { Duration: 30, }, }, }, msg: "CombinedBoundCheck", points: slices.Concat(repeat([]bool{false, true}, 2), repeat([]bool{false, false}, 27)), expD: 10, exp: true, }, } for _, c := range cases { d, passed := c.rule.Check(c.points) assertEq(t, c.msg, c.expD, d) assertEq(t, c.msg, c.exp, passed) } } func repeat[S ~[]E, E any](x S, count int) []S { var slices []S for range count { tmp := make([]E, len(x)) copy(tmp, x) slices = append(slices, tmp) } return slices } func mod[S ~[][]E, E any](x S, val E, i int) S { x[i][0] = val return x } func assertEq(t *testing.T, msg string, exp, act any) { t.Helper() if exp != act { t.Fatalf("failed to test for %s. exp=[%v] but act=[%v]", msg, exp, act) } } // TestAlertRule_ZeroDurationGeneralRule guards against a config-reachable DoS: // a general rule with Duration:0 (the API validates Duration as "optional" with // no minimum) previously hit fail*100/total with total==0, panicking with an // integer divide-by-zero inside checkStatus — which has no recover and would // take down the whole alert goroutine. boundCheck now treats duration<=0 as a // passed (no-op) rule, so Check must return without panicking. func TestAlertRule_ZeroDurationGeneralRule(t *testing.T) { defer func() { if r := recover(); r != nil { t.Fatalf("Check panicked on a Duration:0 general rule (config-reachable DoS): %v", r) } }() rule := &AlertRule{ Rules: []*Rule{{Type: "cpu", Duration: 0}}, } // The only contract here is "do not panic". A zero-duration rule is skipped, // so it contributes nothing to the verdict and max stays 0. maxD, _ := rule.Check([][]bool{{true}, {false}}) if maxD != 0 { t.Fatalf("a skipped Duration:0 rule must not contribute to max, got %d", maxD) } } // Mixing a valid rule with a zero-duration rule must also be safe: the zero // rule is skipped, the real rule still drives the verdict. func TestAlertRule_ZeroDurationMixedWithValidRule(t *testing.T) { defer func() { if r := recover(); r != nil { t.Fatalf("Check panicked on a mixed zero/valid rule set: %v", r) } }() rule := &AlertRule{ Rules: []*Rule{ {Type: "cpu", Duration: 0}, {Type: "cpu", Duration: 3}, }, } maxD, _ := rule.Check([][]bool{{true, false}, {true, false}, {true, false}}) if maxD != 3 { t.Fatalf("the valid Duration:3 rule must still set max=3, got %d", maxD) } } // trimSamples mirrors singleton.checkStatus retention: keep the most recent // `window` samples, clear when window<=0. window comes from RetentionWindow(), // the production code under test. func trimSamples(samples [][]bool, window int) [][]bool { if window <= 0 { return samples[:0] } else if window < len(samples) { return samples[len(samples)-window:] } return samples } // TestAlertRule_GeneralRuleAccumulatesSamples is a regression guard: a normal // Duration>1 general rule must be able to fire. checkStatus appends one sample // per tick then trims to the retention window; if the window is derived from // Check's verdict (which is 0 while the rule is still filling) the history is // wiped every tick, the window never reaches Duration, and the alert never // raises. RetentionWindow() must keep enough samples for the rule to converge. func TestAlertRule_GeneralRuleAccumulatesSamples(t *testing.T) { const duration = 10 rule := &AlertRule{ Rules: []*Rule{{Type: "cpu", Duration: duration}}, } var samples [][]bool var lastPassed bool maxLen := 0 for tick := 0; tick < duration*3; tick++ { samples = append(samples, []bool{false}) // failing sample _, lastPassed = rule.Check(samples) samples = trimSamples(samples, rule.RetentionWindow()) if len(samples) > maxLen { maxLen = len(samples) } } if maxLen < duration { t.Fatalf("samples never accumulated to Duration: max window reached %d, want >= %d", maxLen, duration) } if lastPassed { t.Fatalf("a server failing every tick must eventually fail the check (passed=false), got passed=true") } } // TestAlertRule_RetentionWindow pins the retention contract directly. func TestAlertRule_RetentionWindow(t *testing.T) { cases := []struct { msg string rule *AlertRule want int }{ {"single general", &AlertRule{Rules: []*Rule{{Type: "cpu", Duration: 10}}}, 10}, {"zero duration only", &AlertRule{Rules: []*Rule{{Type: "cpu", Duration: 0}}}, 0}, {"mixed picks max", &AlertRule{Rules: []*Rule{{Type: "cpu", Duration: 0}, {Type: "cpu", Duration: 7}}}, 7}, {"offline looks back one", &AlertRule{Rules: []*Rule{{Type: "offline", Duration: 30}}}, 1}, {"cycle looks back one", &AlertRule{Rules: []*Rule{{Type: "net_in_speed_cycle"}}}, 1}, } for _, c := range cases { if got := c.rule.RetentionWindow(); got != c.want { t.Fatalf("%s: RetentionWindow()=%d want %d", c.msg, got, c.want) } } }