package api import ( "fmt" "strconv" "testing" "time" "github.com/google/uuid" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" "golang.org/x/exp/rand" "github.com/grafana/grafana/pkg/services/folder" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) var allNoData = []apimodels.NoDataState{ apimodels.OK, apimodels.NoData, apimodels.Alerting, } var allExecError = []apimodels.ExecutionErrorState{ apimodels.ErrorErrState, apimodels.AlertingErrState, } func config(t *testing.T) *setting.UnifiedAlertingSettings { t.Helper() baseInterval := time.Duration(rand.Intn(99)+1) * time.Second result := &setting.UnifiedAlertingSettings{ BaseInterval: baseInterval, DefaultRuleEvaluationInterval: baseInterval * time.Duration(rand.Intn(9)+1), } t.Logf("Config Base interval is [%v]", result.BaseInterval) return result } func validRule() apimodels.PostableExtendedRuleNode { forDuration := model.Duration(rand.Int63n(1000)) return apimodels.PostableExtendedRuleNode{ ApiRuleNode: &apimodels.ApiRuleNode{ For: &forDuration, Labels: map[string]string{ "test-label": "data", }, Annotations: map[string]string{ "test-annotation": "data", }, }, GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ Title: fmt.Sprintf("TEST-ALERT-%d", rand.Int63()), Condition: "A", Data: []apimodels.AlertQuery{ { RefID: "A", QueryType: "TEST", RelativeTimeRange: apimodels.RelativeTimeRange{ From: 10, To: 0, }, DatasourceUID: "DATASOURCE_TEST", Model: nil, }, }, UID: util.GenerateShortUID(), NoDataState: allNoData[rand.Intn(len(allNoData))], ExecErrState: allExecError[rand.Intn(len(allExecError))], }, } } func validGroup(cfg *setting.UnifiedAlertingSettings, rules ...apimodels.PostableExtendedRuleNode) apimodels.PostableRuleGroupConfig { return apimodels.PostableRuleGroupConfig{ Name: "TEST-ALERTS-" + util.GenerateShortUID(), Interval: model.Duration(cfg.BaseInterval * time.Duration(rand.Int63n(10))), Rules: rules, } } func randFolder() *folder.Folder { return &folder.Folder{ ID: rand.Int63(), UID: util.GenerateShortUID(), Title: "TEST-FOLDER-" + util.GenerateShortUID(), // URL: "", // Version: 0, Created: time.Time{}, Updated: time.Time{}, // UpdatedBy: 0, // CreatedBy: 0, // HasACL: false, } } func TestValidateCondition(t *testing.T) { testcases := []struct { name string condition string data []apimodels.AlertQuery errorMsg string }{ { name: "error when condition is empty", condition: "", data: []apimodels.AlertQuery{}, errorMsg: "condition cannot be empty", }, { name: "error when data is empty", condition: "A", data: []apimodels.AlertQuery{}, errorMsg: "no query/expressions specified", }, { name: "error when condition does not exist", condition: "A", data: []apimodels.AlertQuery{ { RefID: "B", }, { RefID: "C", }, }, errorMsg: "condition A does not exist, must be one of [B,C]", }, { name: "error when duplicated refId", condition: "A", data: []apimodels.AlertQuery{ { RefID: "A", }, { RefID: "A", }, }, errorMsg: "refID 'A' is already used by query/expression at index 0", }, { name: "error when refId is empty", condition: "A", data: []apimodels.AlertQuery{ { RefID: "", }, { RefID: "A", }, }, errorMsg: "refID is not specified for data query/expression at index 0", }, { name: "valid case", condition: "B", data: []apimodels.AlertQuery{ { RefID: "A", }, { RefID: "B", }, }, }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { err := validateCondition(tc.condition, tc.data) if tc.errorMsg == "" { require.NoError(t, err) } else { require.ErrorContains(t, err, tc.errorMsg) } }) } } func TestValidateRuleGroup(t *testing.T) { orgId := rand.Int63() folder := randFolder() rules := make([]apimodels.PostableExtendedRuleNode, 0, rand.Intn(4)+1) for i := 0; i < cap(rules); i++ { rules = append(rules, validRule()) } cfg := config(t) t.Run("should validate struct and rules", func(t *testing.T) { g := validGroup(cfg, rules...) alerts, err := validateRuleGroup(&g, orgId, folder, cfg) require.NoError(t, err) require.Len(t, alerts, len(rules)) }) t.Run("should default to default interval from config if group interval is 0", func(t *testing.T) { g := validGroup(cfg, rules...) g.Interval = 0 alerts, err := validateRuleGroup(&g, orgId, folder, cfg) require.NoError(t, err) for _, alert := range alerts { require.Equal(t, int64(cfg.DefaultRuleEvaluationInterval.Seconds()), alert.IntervalSeconds) require.False(t, alert.HasPause) } }) t.Run("should show the payload has isPaused field", func(t *testing.T) { for _, rule := range rules { isPaused := true rule.GrafanaManagedAlert.IsPaused = &isPaused isPaused = !(isPaused) } g := validGroup(cfg, rules...) alerts, err := validateRuleGroup(&g, orgId, folder, cfg) require.NoError(t, err) for _, alert := range alerts { require.True(t, alert.HasPause) } }) } func TestValidateRuleGroupFailures(t *testing.T) { orgId := rand.Int63() folder := randFolder() cfg := config(t) testCases := []struct { name string group func() *apimodels.PostableRuleGroupConfig assert func(t *testing.T, apiModel *apimodels.PostableRuleGroupConfig, err error) }{ { name: "fail if title is empty", group: func() *apimodels.PostableRuleGroupConfig { g := validGroup(cfg) g.Name = "" return &g }, }, { name: "fail if title is too long", group: func() *apimodels.PostableRuleGroupConfig { g := validGroup(cfg) for len(g.Name) < store.AlertRuleMaxRuleGroupNameLength { g.Name += g.Name } return &g }, }, { name: "fail if interval is negative", group: func() *apimodels.PostableRuleGroupConfig { g := validGroup(cfg) g.Interval = model.Duration(-(rand.Int63n(1000) + 1)) return &g }, }, { name: "fail if interval is not aligned with base interval", group: func() *apimodels.PostableRuleGroupConfig { g := validGroup(cfg) g.Interval = model.Duration(cfg.BaseInterval + time.Duration(rand.Intn(10)+1)*time.Second) return &g }, }, { name: "fail if two rules have same UID", group: func() *apimodels.PostableRuleGroupConfig { r1 := validRule() r2 := validRule() uid := util.GenerateShortUID() r1.GrafanaManagedAlert.UID = uid r2.GrafanaManagedAlert.UID = uid g := validGroup(cfg, r1, r2) return &g }, assert: func(t *testing.T, apiModel *apimodels.PostableRuleGroupConfig, err error) { require.Contains(t, err.Error(), apiModel.Rules[0].GrafanaManagedAlert.UID) }, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { g := testCase.group() _, err := validateRuleGroup(g, orgId, folder, cfg) require.Error(t, err) if testCase.assert != nil { testCase.assert(t, g, err) } }) } } func TestValidateRuleNode_NoUID(t *testing.T) { orgId := rand.Int63() folder := randFolder() name := util.GenerateShortUID() var cfg = config(t) interval := cfg.BaseInterval * time.Duration(rand.Int63n(10)+1) testCases := []struct { name string rule func() *apimodels.PostableExtendedRuleNode assert func(t *testing.T, model *apimodels.PostableExtendedRuleNode, rule *models.AlertRule) }{ { name: "coverts api model to AlertRule", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() return &r }, assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { require.Equal(t, int64(0), alert.ID) require.Equal(t, orgId, alert.OrgID) require.Equal(t, api.GrafanaManagedAlert.Title, alert.Title) require.Equal(t, api.GrafanaManagedAlert.Condition, alert.Condition) require.Equal(t, AlertQueriesFromApiAlertQueries(api.GrafanaManagedAlert.Data), alert.Data) require.Equal(t, time.Time{}, alert.Updated) require.Equal(t, int64(interval.Seconds()), alert.IntervalSeconds) require.Equal(t, int64(0), alert.Version) require.Equal(t, api.GrafanaManagedAlert.UID, alert.UID) require.Equal(t, folder.UID, alert.NamespaceUID) require.Nil(t, alert.DashboardUID) require.Nil(t, alert.PanelID) require.Equal(t, name, alert.RuleGroup) require.Equal(t, models.NoDataState(api.GrafanaManagedAlert.NoDataState), alert.NoDataState) require.Equal(t, models.ExecutionErrorState(api.GrafanaManagedAlert.ExecErrState), alert.ExecErrState) require.Equal(t, time.Duration(*api.ApiRuleNode.For), alert.For) require.Equal(t, api.ApiRuleNode.Annotations, alert.Annotations) require.Equal(t, api.ApiRuleNode.Labels, alert.Labels) }, }, { name: "coverts api without ApiRuleNode", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.ApiRuleNode = nil return &r }, assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { require.Equal(t, time.Duration(0), alert.For) require.Nil(t, alert.Annotations) require.Nil(t, alert.Labels) }, }, { name: "defaults to NoData if NoDataState is empty", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert.NoDataState = "" return &r }, assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { require.Equal(t, models.NoData, alert.NoDataState) }, }, { name: "defaults to Alerting if ExecErrState is empty", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert.ExecErrState = "" return &r }, assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { require.Equal(t, models.AlertingErrState, alert.ExecErrState) }, }, { name: "extracts Dashboard UID and Panel Id from annotations", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.ApiRuleNode.Annotations = map[string]string{ models.DashboardUIDAnnotation: util.GenerateShortUID(), models.PanelIDAnnotation: strconv.Itoa(rand.Int()), } return &r }, assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { require.Equal(t, api.ApiRuleNode.Annotations[models.DashboardUIDAnnotation], *alert.DashboardUID) panelId, err := strconv.Atoi(api.ApiRuleNode.Annotations[models.PanelIDAnnotation]) require.NoError(t, err) require.Equal(t, int64(panelId), *alert.PanelID) }, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { r := testCase.rule() r.GrafanaManagedAlert.UID = "" alert, err := validateRuleNode(r, name, interval, orgId, folder, cfg) require.NoError(t, err) testCase.assert(t, r, alert) }) } t.Run("accepts empty group name", func(t *testing.T) { r := validRule() alert, err := validateRuleNode(&r, "", interval, orgId, folder, cfg) require.NoError(t, err) require.Equal(t, "", alert.RuleGroup) }) } func TestValidateRuleNodeFailures_NoUID(t *testing.T) { orgId := rand.Int63() folder := randFolder() cfg := config(t) testCases := []struct { name string interval *time.Duration rule func() *apimodels.PostableExtendedRuleNode assert func(t *testing.T, model *apimodels.PostableExtendedRuleNode, err error) allowedIfNoUId bool }{ { name: "fail if GrafanaManagedAlert is not specified", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert = nil return &r }, }, { name: "fail if title is empty", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert.Title = "" return &r }, }, { name: "fail if title is too long", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() for len(r.GrafanaManagedAlert.Title) < store.AlertRuleMaxTitleLength { r.GrafanaManagedAlert.Title += r.GrafanaManagedAlert.Title } return &r }, }, { name: "fail if NoDataState is not known", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert.NoDataState = apimodels.NoDataState(util.GenerateShortUID()) return &r }, }, { name: "fail if ExecErrState is not known", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert.ExecErrState = apimodels.ExecutionErrorState(util.GenerateShortUID()) return &r }, }, { name: "fail if there are not data (nil)", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert.Data = nil return &r }, }, { name: "fail if there are not data (empty)", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert.Data = make([]apimodels.AlertQuery, 0, 1) return &r }, }, { name: "fail if Dashboard UID is specified but not Panel ID", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.ApiRuleNode.Annotations = map[string]string{ models.DashboardUIDAnnotation: util.GenerateShortUID(), } return &r }, }, { name: "fail if Dashboard UID is specified and Panel ID is NaN", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.ApiRuleNode.Annotations = map[string]string{ models.DashboardUIDAnnotation: util.GenerateShortUID(), models.PanelIDAnnotation: util.GenerateShortUID(), } return &r }, }, { name: "fail if PanelID is specified but not Dashboard UID ", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.ApiRuleNode.Annotations = map[string]string{ models.PanelIDAnnotation: "0", } return &r }, }, { name: "fail if Condition is empty", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert.Condition = "" return &r }, }, { name: "fail if Data is empty", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert.Data = nil return &r }, }, { name: "fail if Condition does not exist", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert.Condition = uuid.NewString() return &r }, }, { name: "fail if Data has duplicate ref ID", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert.Data = append(r.GrafanaManagedAlert.Data, r.GrafanaManagedAlert.Data...) return &r }, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { r := testCase.rule() if r.GrafanaManagedAlert != nil { r.GrafanaManagedAlert.UID = "" } interval := cfg.BaseInterval if testCase.interval != nil { interval = *testCase.interval } _, err := validateRuleNode(r, "", interval, orgId, folder, cfg) require.Error(t, err) if testCase.assert != nil { testCase.assert(t, r, err) } }) } } func TestValidateRuleNode_UID(t *testing.T) { orgId := rand.Int63() folder := randFolder() name := util.GenerateShortUID() var cfg = config(t) interval := cfg.BaseInterval * time.Duration(rand.Int63n(10)+1) testCases := []struct { name string rule func() *apimodels.PostableExtendedRuleNode assert func(t *testing.T, model *apimodels.PostableExtendedRuleNode, rule *models.AlertRule) }{ { name: "use empty Title", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert.Title = "" return &r }, assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { require.Equal(t, "", alert.Title) }, }, { name: "use empty NoData if NoDataState is empty", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert.NoDataState = "" return &r }, assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { require.Equal(t, models.NoDataState(""), alert.NoDataState) }, }, { name: "use empty Alerting if ExecErrState is empty", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert.ExecErrState = "" return &r }, assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { require.Equal(t, models.ExecutionErrorState(""), alert.ExecErrState) }, }, { name: "use empty Condition and Data if they are empty", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert.Condition = "" r.GrafanaManagedAlert.Data = nil if rand.Int63()%2 == 0 { r.GrafanaManagedAlert.Data = make([]apimodels.AlertQuery, 0) } return &r }, assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { require.Equal(t, "", alert.Condition) require.Len(t, alert.Data, 0) }, }, { name: "extracts Dashboard UID and Panel Id from annotations", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.ApiRuleNode.Annotations = map[string]string{ models.DashboardUIDAnnotation: util.GenerateShortUID(), models.PanelIDAnnotation: strconv.Itoa(rand.Int()), } return &r }, assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) { require.Equal(t, api.ApiRuleNode.Annotations[models.DashboardUIDAnnotation], *alert.DashboardUID) panelId, err := strconv.Atoi(api.ApiRuleNode.Annotations[models.PanelIDAnnotation]) require.NoError(t, err) require.Equal(t, int64(panelId), *alert.PanelID) }, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { r := testCase.rule() alert, err := validateRuleNode(r, name, interval, orgId, folder, cfg) require.NoError(t, err) testCase.assert(t, r, alert) }) } t.Run("accepts empty group name", func(t *testing.T) { r := validRule() alert, err := validateRuleNode(&r, "", interval, orgId, folder, cfg) require.NoError(t, err) require.Equal(t, "", alert.RuleGroup) }) } func TestValidateRuleNodeFailures_UID(t *testing.T) { orgId := rand.Int63() folder := randFolder() cfg := config(t) testCases := []struct { name string interval *time.Duration rule func() *apimodels.PostableExtendedRuleNode assert func(t *testing.T, model *apimodels.PostableExtendedRuleNode, err error) }{ { name: "fail if GrafanaManagedAlert is not specified", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert = nil return &r }, }, { name: "fail if title is too long", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() for len(r.GrafanaManagedAlert.Title) < store.AlertRuleMaxTitleLength { r.GrafanaManagedAlert.Title += r.GrafanaManagedAlert.Title } return &r }, }, { name: "fail if there are not data (nil) but condition is set", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert.Data = nil r.GrafanaManagedAlert.Condition = "A" return &r }, }, { name: "fail if there are not data (empty) but condition is set", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.GrafanaManagedAlert.Data = make([]apimodels.AlertQuery, 0, 1) r.GrafanaManagedAlert.Condition = "A" return &r }, }, { name: "fail if Dashboard UID is specified but not Panel ID", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.ApiRuleNode.Annotations = map[string]string{ models.DashboardUIDAnnotation: util.GenerateShortUID(), } return &r }, }, { name: "fail if Dashboard UID is specified and Panel ID is NaN", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.ApiRuleNode.Annotations = map[string]string{ models.DashboardUIDAnnotation: util.GenerateShortUID(), models.PanelIDAnnotation: util.GenerateShortUID(), } return &r }, }, { name: "fail if PanelID is specified but not Dashboard UID ", rule: func() *apimodels.PostableExtendedRuleNode { r := validRule() r.ApiRuleNode.Annotations = map[string]string{ models.PanelIDAnnotation: "0", } return &r }, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { r := testCase.rule() interval := cfg.BaseInterval if testCase.interval != nil { interval = *testCase.interval } _, err := validateRuleNode(r, "", interval, orgId, folder, cfg) require.Error(t, err) if testCase.assert != nil { testCase.assert(t, r, err) } }) } } func TestValidateRuleNodeIntervalFailures(t *testing.T) { cfg := config(t) testCases := []struct { name string interval time.Duration }{ { name: "fail if interval is negative", interval: -time.Duration(rand.Int63n(10)+1) * time.Second, }, { name: "fail if interval is 0", interval: 0, }, { name: "fail if interval is not multiple of base interval", interval: cfg.BaseInterval + time.Duration(rand.Int63n(int64(cfg.BaseInterval.Seconds())-2)+1)*time.Second, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { r := validRule() _, err := validateRuleNode(&r, util.GenerateShortUID(), testCase.interval, rand.Int63(), randFolder(), cfg) require.Error(t, err) }) } }