package api import ( "context" "encoding/json" "errors" "fmt" "math/rand" "net/http" "net/url" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" models2 "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" acMock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/datasources" 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/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/schedule" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) func TestCalculateChanges(t *testing.T) { orgId := rand.Int63() t.Run("detects alerts that need to be added", func(t *testing.T) { fakeStore := store.NewFakeRuleStore(t) groupKey := models.GenerateGroupKey(orgId) submitted := models.GenerateAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withOrgID(orgId), simulateSubmitted, withoutUID)) changes, err := calculateChanges(context.Background(), fakeStore, groupKey, submitted) require.NoError(t, err) require.Len(t, changes.New, len(submitted)) require.Empty(t, changes.Delete) require.Empty(t, changes.Update) outerloop: for _, expected := range submitted { for _, rule := range changes.New { if len(expected.Diff(rule)) == 0 { continue outerloop } } require.Fail(t, "changes did not contain rule that was submitted") } }) t.Run("detects alerts that need to be deleted", func(t *testing.T) { groupKey := models.GenerateGroupKey(orgId) inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withGroupKey(groupKey))) fakeStore := store.NewFakeRuleStore(t) fakeStore.PutRule(context.Background(), inDatabase...) changes, err := calculateChanges(context.Background(), fakeStore, groupKey, make([]*models.AlertRule, 0)) require.NoError(t, err) require.Equal(t, groupKey, changes.GroupKey) require.Empty(t, changes.New) require.Empty(t, changes.Update) require.Len(t, changes.Delete, len(inDatabaseMap)) for _, toDelete := range changes.Delete { require.Contains(t, inDatabaseMap, toDelete.UID) db := inDatabaseMap[toDelete.UID] require.Equal(t, db, toDelete) } require.Contains(t, changes.AffectedGroups, groupKey) require.Equal(t, models.RulesGroup(inDatabase), changes.AffectedGroups[groupKey]) }) t.Run("should detect alerts that needs to be updated", func(t *testing.T) { groupKey := models.GenerateGroupKey(orgId) inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withGroupKey(groupKey))) submittedMap, submitted := models.GenerateUniqueAlertRules(len(inDatabase), models.AlertRuleGen(simulateSubmitted, withGroupKey(groupKey), withUIDs(inDatabaseMap))) fakeStore := store.NewFakeRuleStore(t) fakeStore.PutRule(context.Background(), inDatabase...) changes, err := calculateChanges(context.Background(), fakeStore, groupKey, submitted) require.NoError(t, err) require.Equal(t, groupKey, changes.GroupKey) require.Len(t, changes.Update, len(inDatabase)) for _, upsert := range changes.Update { require.NotNil(t, upsert.Existing) require.Equal(t, upsert.Existing.UID, upsert.New.UID) require.Equal(t, inDatabaseMap[upsert.Existing.UID], upsert.Existing) require.Equal(t, submittedMap[upsert.Existing.UID], upsert.New) require.NotEmpty(t, upsert.Diff) } require.Empty(t, changes.Delete) require.Empty(t, changes.New) require.Contains(t, changes.AffectedGroups, groupKey) require.Equal(t, models.RulesGroup(inDatabase), changes.AffectedGroups[groupKey]) }) t.Run("should include only if there are changes ignoring specific fields", func(t *testing.T) { groupKey := models.GenerateGroupKey(orgId) _, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withGroupKey(groupKey))) submitted := make([]*models.AlertRule, 0, len(inDatabase)) for _, rule := range inDatabase { r := models.CopyRule(rule) // Ignore difference in the following fields as submitted models do not have them set r.ID = rand.Int63() r.Version = rand.Int63() r.Updated = r.Updated.Add(1 * time.Minute) submitted = append(submitted, r) } fakeStore := store.NewFakeRuleStore(t) fakeStore.PutRule(context.Background(), inDatabase...) changes, err := calculateChanges(context.Background(), fakeStore, groupKey, submitted) require.NoError(t, err) require.Empty(t, changes.Update) require.Empty(t, changes.Delete) require.Empty(t, changes.New) }) t.Run("should patch rule with UID specified by existing rule", func(t *testing.T) { testCases := []struct { name string mutator func(r *models.AlertRule) }{ { name: "title is empty", mutator: func(r *models.AlertRule) { r.Title = "" }, }, { name: "condition and data are empty", mutator: func(r *models.AlertRule) { r.Condition = "" r.Data = nil }, }, { name: "ExecErrState is empty", mutator: func(r *models.AlertRule) { r.ExecErrState = "" }, }, { name: "NoDataState is empty", mutator: func(r *models.AlertRule) { r.NoDataState = "" }, }, { name: "For is 0", mutator: func(r *models.AlertRule) { r.For = 0 }, }, } dbRule := models.AlertRuleGen(withOrgID(orgId))() fakeStore := store.NewFakeRuleStore(t) fakeStore.PutRule(context.Background(), dbRule) groupKey := models.GenerateGroupKey(orgId) for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { expected := models.AlertRuleGen(simulateSubmitted, testCase.mutator)() expected.UID = dbRule.UID submitted := *expected changes, err := calculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRule{&submitted}) require.NoError(t, err) require.Len(t, changes.Update, 1) ch := changes.Update[0] require.Equal(t, ch.Existing, dbRule) fixed := *expected models.PatchPartialAlertRule(dbRule, &fixed) require.Equal(t, fixed, *ch.New) }) } }) t.Run("should be able to find alerts by UID in other group/namespace", func(t *testing.T) { sourceGroupKey := models.GenerateGroupKey(orgId) inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(10)+10, models.AlertRuleGen(withGroupKey(sourceGroupKey))) fakeStore := store.NewFakeRuleStore(t) fakeStore.PutRule(context.Background(), inDatabase...) namespace := randFolder() groupName := util.GenerateShortUID() groupKey := models.AlertRuleGroupKey{ OrgID: orgId, NamespaceUID: namespace.Uid, RuleGroup: groupName, } submittedMap, submitted := models.GenerateUniqueAlertRules(rand.Intn(len(inDatabase)-5)+5, models.AlertRuleGen(simulateSubmitted, withGroupKey(groupKey), withUIDs(inDatabaseMap))) changes, err := calculateChanges(context.Background(), fakeStore, groupKey, submitted) require.NoError(t, err) require.Equal(t, groupKey, changes.GroupKey) require.Empty(t, changes.Delete) require.Empty(t, changes.New) require.Len(t, changes.Update, len(submitted)) for _, update := range changes.Update { require.NotNil(t, update.Existing) require.Equal(t, update.Existing.UID, update.New.UID) require.Equal(t, inDatabaseMap[update.Existing.UID], update.Existing) require.Equal(t, submittedMap[update.Existing.UID], update.New) require.NotEmpty(t, update.Diff) } require.Contains(t, changes.AffectedGroups, sourceGroupKey) require.NotContains(t, changes.AffectedGroups, groupKey) // because there is no such group in database yet require.Len(t, changes.AffectedGroups[sourceGroupKey], len(inDatabase)) }) t.Run("should fail when submitted rule has UID that does not exist in db", func(t *testing.T) { fakeStore := store.NewFakeRuleStore(t) groupKey := models.GenerateGroupKey(orgId) submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted)() require.NotEqual(t, "", submitted.UID) _, err := calculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRule{submitted}) require.Error(t, err) }) t.Run("should fail if cannot fetch current rules in the group", func(t *testing.T) { fakeStore := store.NewFakeRuleStore(t) expectedErr := errors.New("TEST ERROR") fakeStore.Hook = func(cmd interface{}) error { switch cmd.(type) { case models.ListAlertRulesQuery: return expectedErr } return nil } groupKey := models.GenerateGroupKey(orgId) submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted, withoutUID)() _, err := calculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRule{submitted}) require.ErrorIs(t, err, expectedErr) }) t.Run("should fail if cannot fetch rule by UID", func(t *testing.T) { fakeStore := store.NewFakeRuleStore(t) expectedErr := errors.New("TEST ERROR") fakeStore.Hook = func(cmd interface{}) error { switch cmd.(type) { case models.GetAlertRulesGroupByRuleUIDQuery: return expectedErr } return nil } groupKey := models.GenerateGroupKey(orgId) submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted)() _, err := calculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRule{submitted}) require.ErrorIs(t, err, expectedErr) }) } func TestRouteDeleteAlertRules(t *testing.T) { getRecordedCommand := func(ruleStore *store.FakeRuleStore) []store.GenericRecordedQuery { results := ruleStore.GetRecordedCommands(func(cmd interface{}) (interface{}, bool) { c, ok := cmd.(store.GenericRecordedQuery) if !ok || c.Name != "DeleteAlertRulesByUID" { return nil, false } return c, ok }) var result []store.GenericRecordedQuery for _, cmd := range results { result = append(result, cmd.(store.GenericRecordedQuery)) } return result } assertRulesDeleted := func(t *testing.T, expectedRules []*models.AlertRule, ruleStore *store.FakeRuleStore, scheduler *schedule.FakeScheduleService) { deleteCommands := getRecordedCommand(ruleStore) require.Len(t, deleteCommands, 1) cmd := deleteCommands[0] actualUIDs := cmd.Params[1].([]string) require.Len(t, actualUIDs, len(expectedRules)) for _, rule := range expectedRules { require.Containsf(t, actualUIDs, rule.UID, "Rule %s was expected to be deleted but it wasn't", rule.UID) } require.Len(t, scheduler.Calls, len(expectedRules)) for _, call := range scheduler.Calls { require.Equal(t, "DeleteAlertRule", call.Method) key, ok := call.Arguments.Get(0).(models.AlertRuleKey) require.Truef(t, ok, "Expected AlertRuleKey but got something else") found := false for _, rule := range expectedRules { if rule.GetKey() == key { found = true break } } require.Truef(t, found, "Key %v was not expected to be submitted to scheduler", key) } } t.Run("when fine-grained access is disabled", func(t *testing.T) { t.Run("viewer should not be authorized", func(t *testing.T) { ruleStore := store.NewFakeRuleStore(t) orgID := rand.Int63() folder := randFolder() ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder)))...) ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID)))...) scheduler := &schedule.FakeScheduleService{} scheduler.On("DeleteAlertRule", mock.Anything).Panic("should not be called") ac := acMock.New().WithDisabled() request := createRequestContext(orgID, models2.ROLE_VIEWER, nil) response := createService(ac, ruleStore, scheduler).RouteDeleteAlertRules(request, folder.Title, "") require.Equalf(t, 401, response.Status(), "Expected 403 but got %d: %v", response.Status(), string(response.Body())) scheduler.AssertNotCalled(t, "DeleteAlertRule") require.Empty(t, getRecordedCommand(ruleStore)) }) t.Run("editor should be able to delete all rules in folder", func(t *testing.T) { ruleStore := store.NewFakeRuleStore(t) orgID := rand.Int63() folder := randFolder() ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) rulesInFolder := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder))) ruleStore.PutRule(context.Background(), rulesInFolder...) ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID)))...) scheduler := &schedule.FakeScheduleService{} scheduler.On("DeleteAlertRule", mock.Anything) ac := acMock.New().WithDisabled() request := createRequestContext(orgID, models2.ROLE_EDITOR, nil) response := createService(ac, ruleStore, scheduler).RouteDeleteAlertRules(request, folder.Title, "") require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body())) assertRulesDeleted(t, rulesInFolder, ruleStore, scheduler) }) t.Run("editor should be able to delete rules in a group in a folder", func(t *testing.T) { ruleStore := store.NewFakeRuleStore(t) orgID := rand.Int63() groupName := util.GenerateShortUID() folder := randFolder() ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) rulesInFolderInGroup := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder), withGroup(groupName))) ruleStore.PutRule(context.Background(), rulesInFolderInGroup...) // rules in different groups but in the same namespace ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder)))...) // rules in the same group but different folder ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withGroup(groupName)))...) scheduler := &schedule.FakeScheduleService{} scheduler.On("DeleteAlertRule", mock.Anything) ac := acMock.New().WithDisabled() request := createRequestContext(orgID, models2.ROLE_EDITOR, nil) response := createService(ac, ruleStore, scheduler).RouteDeleteAlertRules(request, folder.Title, groupName) require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body())) assertRulesDeleted(t, rulesInFolderInGroup, ruleStore, scheduler) }) t.Run("editor shouldn't be able to delete provisioned rules", func(t *testing.T) { ruleStore := store.NewFakeRuleStore(t) orgID := rand.Int63() folder := randFolder() ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) rulesInFolder := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder))) ruleStore.PutRule(context.Background(), rulesInFolder...) ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID)))...) scheduler := &schedule.FakeScheduleService{} scheduler.On("DeleteAlertRule", mock.Anything) ac := acMock.New().WithDisabled() svc := createService(ac, ruleStore, scheduler) err := svc.provenanceStore.SetProvenance(context.Background(), rulesInFolder[0], orgID, models.ProvenanceAPI) require.NoError(t, err) request := createRequestContext(orgID, models2.ROLE_EDITOR, nil) response := svc.RouteDeleteAlertRules(request, folder.Title, "") require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body())) assertRulesDeleted(t, rulesInFolder[1:], ruleStore, scheduler) }) }) t.Run("when fine-grained access is enabled", func(t *testing.T) { t.Run("and user does not have access to any of data sources used by alert rules", func(t *testing.T) { ruleStore := store.NewFakeRuleStore(t) orgID := rand.Int63() folder := randFolder() ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder)))...) ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID)))...) scheduler := &schedule.FakeScheduleService{} scheduler.On("DeleteAlertRule", mock.Anything).Panic("should not be called") ac := acMock.New() request := createRequestContext(orgID, "None", nil) response := createService(ac, ruleStore, scheduler).RouteDeleteAlertRules(request, folder.Title, "") require.Equalf(t, 401, response.Status(), "Expected 403 but got %d: %v", response.Status(), string(response.Body())) scheduler.AssertNotCalled(t, "DeleteAlertRule") require.Empty(t, getRecordedCommand(ruleStore)) }) t.Run("and user has access to all alert rules", func(t *testing.T) { t.Run("should delete all rules", func(t *testing.T) { ruleStore := store.NewFakeRuleStore(t) orgID := rand.Int63() folder := randFolder() ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) rulesInFolder := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder))) ruleStore.PutRule(context.Background(), rulesInFolder...) ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID)))...) scheduler := &schedule.FakeScheduleService{} scheduler.On("DeleteAlertRule", mock.Anything) ac := acMock.New().WithPermissions(createPermissionsForRules(rulesInFolder)) request := createRequestContext(orgID, "None", nil) response := createService(ac, ruleStore, scheduler).RouteDeleteAlertRules(request, folder.Title, "") require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body())) assertRulesDeleted(t, rulesInFolder, ruleStore, scheduler) }) t.Run("shouldn't be able to delete provisioned rules", func(t *testing.T) { ruleStore := store.NewFakeRuleStore(t) orgID := rand.Int63() folder := randFolder() ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) rulesInFolder := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder))) ruleStore.PutRule(context.Background(), rulesInFolder...) ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID)))...) scheduler := &schedule.FakeScheduleService{} scheduler.On("DeleteAlertRule", mock.Anything) ac := acMock.New().WithPermissions(createPermissionsForRules(rulesInFolder)) svc := createService(ac, ruleStore, scheduler) err := svc.provenanceStore.SetProvenance(context.Background(), rulesInFolder[0], orgID, models.ProvenanceAPI) require.NoError(t, err) request := createRequestContext(orgID, "None", nil) response := svc.RouteDeleteAlertRules(request, folder.Title, "") require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body())) assertRulesDeleted(t, rulesInFolder[1:], ruleStore, scheduler) }) }) t.Run("and user has access to data sources of some of alert rules", func(t *testing.T) { t.Run("should delete only those that are accessible in folder", func(t *testing.T) { ruleStore := store.NewFakeRuleStore(t) orgID := rand.Int63() folder := randFolder() ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) authorizedRulesInFolder := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder))) ruleStore.PutRule(context.Background(), authorizedRulesInFolder...) // more rules in the same namespace but user does not have access to them ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder)))...) ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID)))...) scheduler := &schedule.FakeScheduleService{} scheduler.On("DeleteAlertRule", mock.Anything) ac := acMock.New().WithPermissions(createPermissionsForRules(authorizedRulesInFolder)) request := createRequestContext(orgID, "None", nil) response := createService(ac, ruleStore, scheduler).RouteDeleteAlertRules(request, folder.Title, "") require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body())) assertRulesDeleted(t, authorizedRulesInFolder, ruleStore, scheduler) }) t.Run("should delete only rules in a group that are authorized", func(t *testing.T) { ruleStore := store.NewFakeRuleStore(t) orgID := rand.Int63() groupName := util.GenerateShortUID() folder := randFolder() ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) authorizedRulesInGroup := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder), withGroup(groupName))) ruleStore.PutRule(context.Background(), authorizedRulesInGroup...) // more rules in the same group but user is not authorized to access them ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder), withGroup(groupName)))...) // rules in different groups but in the same namespace ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder)))...) // rules in the same group but different folder ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withGroup(groupName)))...) scheduler := &schedule.FakeScheduleService{} scheduler.On("DeleteAlertRule", mock.Anything) ac := acMock.New().WithPermissions(createPermissionsForRules(authorizedRulesInGroup)) request := createRequestContext(orgID, "None", nil) response := createService(ac, ruleStore, scheduler).RouteDeleteAlertRules(request, folder.Title, groupName) require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body())) assertRulesDeleted(t, authorizedRulesInGroup, ruleStore, scheduler) }) }) }) } func TestRouteGetNamespaceRulesConfig(t *testing.T) { t.Run("fine-grained access is enabled", func(t *testing.T) { t.Run("should return rules for which user has access to data source", func(t *testing.T) { orgID := rand.Int63() folder := randFolder() ruleStore := store.NewFakeRuleStore(t) ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) expectedRules := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder))) ruleStore.PutRule(context.Background(), expectedRules...) ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder)))...) ac := acMock.New().WithPermissions(createPermissionsForRules(expectedRules)) req := createRequestContext(orgID, "", nil) response := createService(ac, ruleStore, nil).RouteGetNamespaceRulesConfig(req, folder.Title) require.Equal(t, http.StatusAccepted, response.Status()) result := &apimodels.NamespaceConfigResponse{} require.NoError(t, json.Unmarshal(response.Body(), result)) require.NotNil(t, result) for namespace, groups := range *result { require.Equal(t, folder.Title, namespace) for _, group := range groups { grouploop: for _, actualRule := range group.Rules { for i, expected := range expectedRules { if actualRule.GrafanaManagedAlert.UID == expected.UID { expectedRules = append(expectedRules[:i], expectedRules[i+1:]...) continue grouploop } } assert.Failf(t, "rule in a group was not found in expected", "rule %s group %s", actualRule.GrafanaManagedAlert.Title, group.Name) } } } assert.Emptyf(t, expectedRules, "not all expected rules were returned") }) }) t.Run("fine-grained access is disabled", func(t *testing.T) { t.Run("should return all rules from folder", func(t *testing.T) { orgID := rand.Int63() folder := randFolder() ruleStore := store.NewFakeRuleStore(t) ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) expectedRules := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder))) ruleStore.PutRule(context.Background(), expectedRules...) ac := acMock.New().WithDisabled() req := createRequestContext(orgID, models2.ROLE_VIEWER, nil) response := createService(ac, ruleStore, nil).RouteGetNamespaceRulesConfig(req, folder.Title) require.Equal(t, http.StatusAccepted, response.Status()) result := &apimodels.NamespaceConfigResponse{} require.NoError(t, json.Unmarshal(response.Body(), result)) require.NotNil(t, result) for namespace, groups := range *result { require.Equal(t, folder.Title, namespace) for _, group := range groups { grouploop: for _, actualRule := range group.Rules { for i, expected := range expectedRules { if actualRule.GrafanaManagedAlert.UID == expected.UID { expectedRules = append(expectedRules[:i], expectedRules[i+1:]...) continue grouploop } } assert.Failf(t, "rule in a group was not found in expected", "rule %s group %s", actualRule.GrafanaManagedAlert.Title, group.Name) } } } assert.Emptyf(t, expectedRules, "not all expected rules were returned") }) }) t.Run("should return the provenance of the alert rules", func(t *testing.T) { orgID := rand.Int63() folder := randFolder() ruleStore := store.NewFakeRuleStore(t) ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) expectedRules := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder))) ruleStore.PutRule(context.Background(), expectedRules...) ac := acMock.New().WithDisabled() svc := createService(ac, ruleStore, nil) // add provenance to the first generated rule rule := &models.AlertRule{ UID: expectedRules[0].UID, } err := svc.provenanceStore.SetProvenance(context.Background(), rule, orgID, models.ProvenanceAPI) require.NoError(t, err) req := createRequestContext(orgID, models2.ROLE_VIEWER, nil) response := svc.RouteGetNamespaceRulesConfig(req, folder.Title) require.Equal(t, http.StatusAccepted, response.Status()) result := &apimodels.NamespaceConfigResponse{} require.NoError(t, json.Unmarshal(response.Body(), result)) require.NotNil(t, result) found := false for namespace, groups := range *result { require.Equal(t, folder.Title, namespace) for _, group := range groups { for _, actualRule := range group.Rules { if actualRule.GrafanaManagedAlert.UID == expectedRules[0].UID { require.Equal(t, models.ProvenanceAPI, actualRule.GrafanaManagedAlert.Provenance) found = true } else { require.Equal(t, models.ProvenanceNone, actualRule.GrafanaManagedAlert.Provenance) } } } } require.True(t, found) }) t.Run("should enforce order of rules in the group", func(t *testing.T) { orgID := rand.Int63() folder := randFolder() ruleStore := store.NewFakeRuleStore(t) ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) groupKey := models.GenerateGroupKey(orgID) groupKey.NamespaceUID = folder.Uid expectedRules := models.GenerateAlertRules(rand.Intn(5)+5, models.AlertRuleGen(withGroupKey(groupKey), models.WithUniqueGroupIndex())) ruleStore.PutRule(context.Background(), expectedRules...) ac := acMock.New().WithDisabled() response := createService(ac, ruleStore, nil).RouteGetNamespaceRulesConfig(createRequestContext(orgID, models2.ROLE_VIEWER, nil), folder.Title) require.Equal(t, http.StatusAccepted, response.Status()) result := &apimodels.NamespaceConfigResponse{} require.NoError(t, json.Unmarshal(response.Body(), result)) require.NotNil(t, result) models.RulesGroup(expectedRules).SortByGroupIndex() require.Contains(t, *result, folder.Title) groups := (*result)[folder.Title] require.Len(t, groups, 1) group := groups[0] require.Equal(t, groupKey.RuleGroup, group.Name) for i, actual := range groups[0].Rules { expected := expectedRules[i] if actual.GrafanaManagedAlert.UID != expected.UID { var actualUIDs []string var expectedUIDs []string for _, rule := range group.Rules { actualUIDs = append(actualUIDs, rule.GrafanaManagedAlert.UID) } for _, rule := range expectedRules { expectedUIDs = append(expectedUIDs, rule.UID) } require.Fail(t, fmt.Sprintf("rules are not sorted by group index. Expected: %v. Actual: %v", expectedUIDs, actualUIDs)) } } }) } func TestRouteGetRulesConfig(t *testing.T) { t.Run("fine-grained access is enabled", func(t *testing.T) { t.Run("should check access to data source", func(t *testing.T) { orgID := rand.Int63() ruleStore := store.NewFakeRuleStore(t) folder1 := randFolder() folder2 := randFolder() ruleStore.Folders[orgID] = []*models2.Folder{folder1, folder2} group1Key := models.GenerateGroupKey(orgID) group1Key.NamespaceUID = folder1.Uid group2Key := models.GenerateGroupKey(orgID) group2Key.NamespaceUID = folder2.Uid group1 := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withGroupKey(group1Key))) group2 := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withGroupKey(group2Key))) ruleStore.PutRule(context.Background(), append(group1, group2...)...) request := createRequestContext(orgID, "", nil) t.Run("and do not return group if user does not have access to one of rules", func(t *testing.T) { ac := acMock.New().WithPermissions(createPermissionsForRules(append(group1, group2[1:]...))) response := createService(ac, ruleStore, nil).RouteGetRulesConfig(request) require.Equal(t, http.StatusOK, response.Status()) result := &apimodels.NamespaceConfigResponse{} require.NoError(t, json.Unmarshal(response.Body(), result)) require.NotNil(t, result) require.Contains(t, *result, folder1.Title) require.NotContains(t, *result, folder2.Title) groups := (*result)[folder1.Title] require.Len(t, groups, 1) require.Equal(t, group1Key.RuleGroup, groups[0].Name) require.Len(t, groups[0].Rules, len(group1)) }) }) }) t.Run("should return rules in group sorted by group index", func(t *testing.T) { orgID := rand.Int63() folder := randFolder() ruleStore := store.NewFakeRuleStore(t) ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) groupKey := models.GenerateGroupKey(orgID) groupKey.NamespaceUID = folder.Uid expectedRules := models.GenerateAlertRules(rand.Intn(5)+5, models.AlertRuleGen(withGroupKey(groupKey), models.WithUniqueGroupIndex())) ruleStore.PutRule(context.Background(), expectedRules...) ac := acMock.New().WithDisabled() response := createService(ac, ruleStore, nil).RouteGetRulesConfig(createRequestContext(orgID, models2.ROLE_VIEWER, nil)) require.Equal(t, http.StatusOK, response.Status()) result := &apimodels.NamespaceConfigResponse{} require.NoError(t, json.Unmarshal(response.Body(), result)) require.NotNil(t, result) models.RulesGroup(expectedRules).SortByGroupIndex() require.Contains(t, *result, folder.Title) groups := (*result)[folder.Title] require.Len(t, groups, 1) group := groups[0] require.Equal(t, groupKey.RuleGroup, group.Name) for i, actual := range groups[0].Rules { expected := expectedRules[i] if actual.GrafanaManagedAlert.UID != expected.UID { var actualUIDs []string var expectedUIDs []string for _, rule := range group.Rules { actualUIDs = append(actualUIDs, rule.GrafanaManagedAlert.UID) } for _, rule := range expectedRules { expectedUIDs = append(expectedUIDs, rule.UID) } require.Fail(t, fmt.Sprintf("rules are not sorted by group index. Expected: %v. Actual: %v", expectedUIDs, actualUIDs)) } } }) } func TestRouteGetRulesGroupConfig(t *testing.T) { t.Run("fine-grained access is enabled", func(t *testing.T) { t.Run("should check access to data source", func(t *testing.T) { orgID := rand.Int63() folder := randFolder() ruleStore := store.NewFakeRuleStore(t) ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) groupKey := models.GenerateGroupKey(orgID) groupKey.NamespaceUID = folder.Uid expectedRules := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withGroupKey(groupKey))) ruleStore.PutRule(context.Background(), expectedRules...) request := createRequestContext(orgID, "", map[string]string{ ":Namespace": folder.Title, ":Groupname": groupKey.RuleGroup, }) t.Run("and return 401 if user does not have access one of rules", func(t *testing.T) { ac := acMock.New().WithPermissions(createPermissionsForRules(expectedRules[1:])) response := createService(ac, ruleStore, nil).RouteGetRulesGroupConfig(request, folder.Title, groupKey.RuleGroup) require.Equal(t, http.StatusUnauthorized, response.Status()) }) t.Run("and return rules if user has access to all of them", func(t *testing.T) { ac := acMock.New().WithPermissions(createPermissionsForRules(expectedRules)) response := createService(ac, ruleStore, nil).RouteGetRulesGroupConfig(request, folder.Title, groupKey.RuleGroup) require.Equal(t, http.StatusAccepted, response.Status()) result := &apimodels.RuleGroupConfigResponse{} require.NoError(t, json.Unmarshal(response.Body(), result)) require.NotNil(t, result) require.Len(t, result.Rules, len(expectedRules)) }) }) }) t.Run("should return rules in group sorted by group index", func(t *testing.T) { orgID := rand.Int63() folder := randFolder() ruleStore := store.NewFakeRuleStore(t) ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) groupKey := models.GenerateGroupKey(orgID) groupKey.NamespaceUID = folder.Uid expectedRules := models.GenerateAlertRules(rand.Intn(5)+5, models.AlertRuleGen(withGroupKey(groupKey), models.WithUniqueGroupIndex())) ruleStore.PutRule(context.Background(), expectedRules...) ac := acMock.New().WithDisabled() response := createService(ac, ruleStore, nil).RouteGetRulesGroupConfig(createRequestContext(orgID, models2.ROLE_VIEWER, nil), folder.Title, groupKey.RuleGroup) require.Equal(t, http.StatusAccepted, response.Status()) result := &apimodels.RuleGroupConfigResponse{} require.NoError(t, json.Unmarshal(response.Body(), result)) require.NotNil(t, result) models.RulesGroup(expectedRules).SortByGroupIndex() for i, actual := range result.Rules { expected := expectedRules[i] if actual.GrafanaManagedAlert.UID != expected.UID { var actualUIDs []string var expectedUIDs []string for _, rule := range result.Rules { actualUIDs = append(actualUIDs, rule.GrafanaManagedAlert.UID) } for _, rule := range expectedRules { expectedUIDs = append(expectedUIDs, rule.UID) } require.Fail(t, fmt.Sprintf("rules are not sorted by group index. Expected: %v. Actual: %v", expectedUIDs, actualUIDs)) } } }) } func TestVerifyProvisionedRulesNotAffected(t *testing.T) { orgID := rand.Int63() group := models.GenerateGroupKey(orgID) affectedGroups := make(map[models.AlertRuleGroupKey]models.RulesGroup) var allRules []*models.AlertRule { rules := models.GenerateAlertRules(rand.Intn(3)+1, models.AlertRuleGen(withGroupKey(group))) allRules = append(allRules, rules...) affectedGroups[group] = rules for i := 0; i < rand.Intn(3)+1; i++ { g := models.GenerateGroupKey(orgID) rules := models.GenerateAlertRules(rand.Intn(3)+1, models.AlertRuleGen(withGroupKey(g))) allRules = append(allRules, rules...) affectedGroups[g] = rules } } ch := &changes{ GroupKey: group, AffectedGroups: affectedGroups, } t.Run("should return error if at least one rule in affected groups is provisioned", func(t *testing.T) { rand.Shuffle(len(allRules), func(i, j int) { allRules[j], allRules[i] = allRules[i], allRules[j] }) storeResult := make(map[string]models.Provenance, len(allRules)) storeResult[allRules[0].UID] = models.ProvenanceAPI storeResult[allRules[1].UID] = models.ProvenanceFile provenanceStore := &provisioning.MockProvisioningStore{} provenanceStore.EXPECT().GetProvenances(mock.Anything, orgID, "alertRule").Return(storeResult, nil) result := verifyProvisionedRulesNotAffected(context.Background(), provenanceStore, orgID, ch) require.Error(t, result) require.ErrorIs(t, result, errProvisionedResource) assert.Contains(t, result.Error(), allRules[0].GetGroupKey().String()) assert.Contains(t, result.Error(), allRules[1].GetGroupKey().String()) }) t.Run("should return nil if all have ProvenanceNone", func(t *testing.T) { storeResult := make(map[string]models.Provenance, len(allRules)) for _, rule := range allRules { storeResult[rule.UID] = models.ProvenanceNone } provenanceStore := &provisioning.MockProvisioningStore{} provenanceStore.EXPECT().GetProvenances(mock.Anything, orgID, "alertRule").Return(storeResult, nil) result := verifyProvisionedRulesNotAffected(context.Background(), provenanceStore, orgID, ch) require.NoError(t, result) }) t.Run("should return nil if no alerts have provisioning status", func(t *testing.T) { provenanceStore := &provisioning.MockProvisioningStore{} provenanceStore.EXPECT().GetProvenances(mock.Anything, orgID, "alertRule").Return(make(map[string]models.Provenance, len(allRules)), nil) result := verifyProvisionedRulesNotAffected(context.Background(), provenanceStore, orgID, ch) require.NoError(t, result) }) } func TestCalculateAutomaticChanges(t *testing.T) { orgID := rand.Int63() t.Run("should mark all rules in affected groups", func(t *testing.T) { group := models.GenerateGroupKey(orgID) rules := models.GenerateAlertRules(10, models.AlertRuleGen(withGroupKey(group))) // copy rules to make sure that the function does not modify the original rules copies := make([]*models.AlertRule, 0, len(rules)) for _, rule := range rules { copies = append(copies, models.CopyRule(rule)) } var updates []ruleUpdate for i := 0; i < 5; i++ { ruleCopy := models.CopyRule(copies[i]) ruleCopy.Title += util.GenerateShortUID() updates = append(updates, ruleUpdate{ Existing: copies[i], New: ruleCopy, }) } // simulate adding new rules, updating a few existing and delete some from the same rule ch := &changes{ GroupKey: group, AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{ group: copies, }, New: models.GenerateAlertRules(2, models.AlertRuleGen(withGroupKey(group))), Update: updates, Delete: rules[5:7], } result := calculateAutomaticChanges(ch) require.NotEqual(t, ch, result) require.Equal(t, ch.GroupKey, result.GroupKey) require.Equal(t, map[models.AlertRuleGroupKey]models.RulesGroup{ group: rules, }, result.AffectedGroups) require.Equal(t, ch.New, result.New) require.Equal(t, rules[5:7], result.Delete) var expected []ruleUpdate expected = append(expected, updates...) // all rules that were not updated directly by user should be added to the for _, rule := range rules[7:] { expected = append(expected, ruleUpdate{ Existing: rule, New: rule, }) } require.Equal(t, expected, result.Update) }) t.Run("should re-index rules in affected groups other than updated", func(t *testing.T) { group := models.GenerateGroupKey(orgID) rules := models.GenerateAlertRules(3, models.AlertRuleGen(withGroupKey(group), models.WithSequentialGroupIndex())) group2 := models.GenerateGroupKey(orgID) rules2 := models.GenerateAlertRules(4, models.AlertRuleGen(withGroupKey(group2), models.WithSequentialGroupIndex())) movedIndex := rand.Intn(len(rules2) - 1) movedRule := rules2[movedIndex] copyRule := models.CopyRule(movedRule) copyRule.RuleGroup = group.RuleGroup copyRule.NamespaceUID = group.NamespaceUID copyRule.RuleGroupIndex = len(rules) update := ruleUpdate{ Existing: movedRule, New: copyRule, } shuffled := make([]*models.AlertRule, 0, len(rules2)) copy(shuffled, rules2) rand.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] }) // simulate moving a rule from one group to another. ch := &changes{ GroupKey: group, AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{ group: rules, group2: shuffled, }, Update: []ruleUpdate{ update, }, } result := calculateAutomaticChanges(ch) require.NotEqual(t, ch, result) require.Equal(t, ch.GroupKey, result.GroupKey) require.Equal(t, ch.AffectedGroups, result.AffectedGroups) require.Equal(t, ch.New, result.New) require.Equal(t, ch.Delete, result.Delete) require.Equal(t, ch.Update, result.Update[0:1]) require.Contains(t, result.Update, update) for _, rule := range rules { assert.Containsf(t, result.Update, ruleUpdate{ Existing: rule, New: rule, }, "automatic changes expected to contain all rules of the updated group") } // calculate expected index of the rules in the source group after the move expectedReindex := make(map[string]int, len(rules2)-1) idx := 1 for _, rule := range rules2 { if rule.UID == movedRule.UID { continue } expectedReindex[rule.UID] = idx idx++ } for _, upd := range result.Update { expectedIdx, ok := expectedReindex[upd.Existing.UID] if !ok { continue } diff := upd.Existing.Diff(upd.New) if upd.Existing.RuleGroupIndex != expectedIdx { require.Lenf(t, diff, 1, fmt.Sprintf("the rule in affected group should be re-indexed to %d but it still has index %d. Moved rule with index %d", expectedIdx, upd.Existing.RuleGroupIndex, movedIndex)) require.Equal(t, "RuleGroupIndex", diff[0].Path) require.Equal(t, expectedIdx, upd.New.RuleGroupIndex) } else { require.Empty(t, diff) } } }) } func createService(ac *acMock.Mock, store *store.FakeRuleStore, scheduler schedule.ScheduleService) *RulerSrv { return &RulerSrv{ xactManager: store, store: store, DatasourceCache: nil, QuotaService: nil, provenanceStore: provisioning.NewFakeProvisioningStore(), scheduleService: scheduler, log: log.New("test"), cfg: nil, ac: ac, } } func createRequestContext(orgID int64, role models2.RoleType, params map[string]string) *models2.ReqContext { uri, _ := url.Parse("http://localhost") ctx := web.Context{Req: &http.Request{ URL: uri, }} if params != nil { ctx.Req = web.SetURLParams(ctx.Req, params) } return &models2.ReqContext{ IsSignedIn: true, SignedInUser: &models2.SignedInUser{ OrgRole: role, OrgId: orgID, }, Context: &ctx, } } func createPermissionsForRules(rules []*models.AlertRule) []accesscontrol.Permission { var permissions []accesscontrol.Permission for _, rule := range rules { for _, query := range rule.Data { permissions = append(permissions, accesscontrol.Permission{ Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(query.DatasourceUID), }) } } return permissions } func withOrgID(orgId int64) func(rule *models.AlertRule) { return func(rule *models.AlertRule) { rule.OrgID = orgId } } func withGroup(groupName string) func(rule *models.AlertRule) { return func(rule *models.AlertRule) { rule.RuleGroup = groupName } } func withNamespace(namespace *models2.Folder) func(rule *models.AlertRule) { return func(rule *models.AlertRule) { rule.NamespaceUID = namespace.Uid } } func withGroupKey(groupKey models.AlertRuleGroupKey) func(rule *models.AlertRule) { return func(rule *models.AlertRule) { rule.RuleGroup = groupKey.RuleGroup rule.OrgID = groupKey.OrgID rule.NamespaceUID = groupKey.NamespaceUID } } // simulateSubmitted resets some fields of the structure that are not populated by API model to model conversion func simulateSubmitted(rule *models.AlertRule) { rule.ID = 0 rule.Version = 0 rule.Updated = time.Time{} } func withoutUID(rule *models.AlertRule) { rule.UID = "" } func withUIDs(uids map[string]*models.AlertRule) func(rule *models.AlertRule) { unused := make([]string, 0, len(uids)) for s := range uids { unused = append(unused, s) } return func(rule *models.AlertRule) { if len(unused) == 0 { return } rule.UID = unused[0] unused = unused[1:] } }