package api import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" "time" amconfig "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/pkg/labels" prommodel "github.com/prometheus/common/model" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" dsfakes "github.com/grafana/grafana/pkg/services/datasources/fakes" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/foldertest" acfakes "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol/fakes" 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/store" "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) const ( existingDSUID = "test-ds" ) func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) { simpleGroup := apimodels.PrometheusRuleGroup{ Name: "Test Group", Interval: prommodel.Duration(1 * time.Minute), Rules: []apimodels.PrometheusRule{ { Alert: "TestAlert", Expr: "up == 0", For: util.Pointer(prommodel.Duration(5 * time.Minute)), Labels: map[string]string{ "severity": "critical", }, }, { Record: "recorded-metric", Expr: "vector(1)", Labels: map[string]string{ "severity": "warning", }, }, }, } t.Run("without datasource UID header should return 400", func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) rc := createRequestCtx() rc.Req.Header.Set(datasourceUIDHeader, "") response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", apimodels.PrometheusRuleGroup{}) require.Equal(t, http.StatusBadRequest, response.Status()) require.Contains(t, string(response.Body()), "Missing datasource UID header") }) t.Run("with invalid datasource should return error", func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) rc := createRequestCtx() rc.Req.Header.Set(datasourceUIDHeader, "non-existing-ds") response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", apimodels.PrometheusRuleGroup{}) require.Equal(t, http.StatusNotFound, response.Status()) }) t.Run("with rule group without evaluation interval should return 202", func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) rc := createRequestCtx() response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) require.Equal(t, http.StatusAccepted, response.Status()) }) t.Run("should replace an existing rule group", func(t *testing.T) { provenanceStore := fakes.NewFakeProvisioningStore() folderService := foldertest.NewFakeService() srv, _, ruleStore := createConvertPrometheusSrv(t, withProvenanceStore(provenanceStore), withFolderService(folderService)) // Create a folder in the root fldr := randFolder() fldr.ParentUID = "" folderService.ExpectedFolder = fldr folderService.ExpectedFolders = []*folder.Folder{fldr} ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr) // And a rule rule := models.RuleGen. With(models.RuleGen.WithNamespaceUID(fldr.UID)). With(models.RuleGen.WithGroupName(simpleGroup.Name)). With(models.RuleGen.WithOrgID(1)). With(models.RuleGen.WithPrometheusOriginalRuleDefinition("123")). GenerateRef() ruleStore.PutRule(context.Background(), rule) rc := createRequestCtx() response := srv.RouteConvertPrometheusPostRuleGroup(rc, fldr.Title, simpleGroup) require.Equal(t, http.StatusAccepted, response.Status()) // Get the rules remaining, err := ruleStore.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{ OrgID: 1, }) require.NoError(t, err) require.Len(t, remaining, 2) // Create a map of rule titles to their expected definitions expectedRules := make(map[string]string) for _, rule := range simpleGroup.Rules { if rule.Alert != "" { promRuleYAML, err := yaml.Marshal(rule) require.NoError(t, err) expectedRules[rule.Alert] = string(promRuleYAML) } else if rule.Record != "" { promRuleYAML, err := yaml.Marshal(rule) require.NoError(t, err) expectedRules[rule.Record] = string(promRuleYAML) } } // Verify each rule matches its expected definition for _, r := range remaining { require.Equal(t, simpleGroup.Name, r.RuleGroup) expectedDef, exists := expectedRules[r.Title] require.True(t, exists, "unexpected rule title: %s", r.Title) promDefinition, err := r.PrometheusRuleDefinition() require.NoError(t, err) require.Equal(t, expectedDef, promDefinition) // Verify provenance was set to ProvenanceConvertedPrometheus prov, err := provenanceStore.GetProvenance(context.Background(), r, 1) require.NoError(t, err) require.Equal(t, models.ProvenanceConvertedPrometheus, prov) } }) t.Run("should fail to replace a provisioned rule group", func(t *testing.T) { provenanceStore := fakes.NewFakeProvisioningStore() folderService := foldertest.NewFakeService() srv, _, ruleStore := createConvertPrometheusSrv(t, withProvenanceStore(provenanceStore), withFolderService(folderService)) // Create a folder in the root fldr := randFolder() fldr.ParentUID = "" folderService.ExpectedFolder = fldr folderService.ExpectedFolders = []*folder.Folder{fldr} ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr) rule := models.RuleGen. With(models.RuleGen.WithNamespaceUID(fldr.UID)). With(models.RuleGen.WithGroupName(simpleGroup.Name)). With(models.RuleGen.WithOrgID(1)). With(models.RuleGen.WithPrometheusOriginalRuleDefinition("123")). GenerateRef() ruleStore.PutRule(context.Background(), rule) // mark the rule as provisioned err := provenanceStore.SetProvenance(context.Background(), rule, 1, models.ProvenanceAPI) require.NoError(t, err) rc := createRequestCtx() response := srv.RouteConvertPrometheusPostRuleGroup(rc, fldr.Title, simpleGroup) require.Equal(t, http.StatusConflict, response.Status()) // Verify the rule is still present remaining, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{ UID: rule.UID, OrgID: rule.OrgID, }) require.NoError(t, err) require.NotNil(t, remaining) }) t.Run("with no access to the datasource should return 403", func(t *testing.T) { acFake := &acfakes.FakeRuleService{} srv, _, _ := createConvertPrometheusSrv(t, withFakeAccessControlRuleService(acFake)) acFake.AuthorizeRuleChangesFunc = func(context.Context, identity.Requester, *store.GroupDelta) error { return datasources.ErrDataSourceAccessDenied } rc := createRequestCtx() response := srv.RouteConvertPrometheusPostRuleGroup(rc, "folder", simpleGroup) require.Equal(t, http.StatusForbidden, response.Status()) require.Contains(t, string(response.Body()), "data source access denied") }) t.Run("when alert rule quota limit exceeded", func(t *testing.T) { quotas := &provisioning.MockQuotaChecker{} quotas.EXPECT().LimitExceeded() srv, _, _ := createConvertPrometheusSrv(t, withQuotaChecker(quotas)) rc := createRequestCtx() response := srv.RouteConvertPrometheusPostRuleGroup(rc, "folder", simpleGroup) require.Equal(t, http.StatusForbidden, response.Status()) require.Contains(t, string(response.Body()), "quota has been exceeded") }) t.Run("with valid pause header values should return 202", func(t *testing.T) { testCases := []struct { name string headerName string headerValue string }{ { name: "true recording rules pause value", headerName: recordingRulesPausedHeader, headerValue: "true", }, { name: "false recording rules pause value", headerName: recordingRulesPausedHeader, headerValue: "false", }, { name: "true alert rules pause value", headerName: alertRulesPausedHeader, headerValue: "true", }, { name: "false alert rules pause value", headerName: alertRulesPausedHeader, headerValue: "false", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) rc := createRequestCtx() rc.Req.Header.Set(tc.headerName, tc.headerValue) response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) require.Equal(t, http.StatusAccepted, response.Status()) }) } }) t.Run("with invalid pause header values should return 400", func(t *testing.T) { testCases := []struct { name string headerName string headerValue string expectedError string }{ { name: "invalid recording rules pause value", headerName: recordingRulesPausedHeader, headerValue: "invalid", expectedError: "Invalid value for header X-Grafana-Alerting-Recording-Rules-Paused: must be 'true' or 'false'", }, { name: "invalid alert rules pause value", headerName: alertRulesPausedHeader, headerValue: "invalid", expectedError: "Invalid value for header X-Grafana-Alerting-Alert-Rules-Paused: must be 'true' or 'false'", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) rc := createRequestCtx() rc.Req.Header.Set(tc.headerName, tc.headerValue) response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) require.Equal(t, http.StatusBadRequest, response.Status()) require.Contains(t, string(response.Body()), tc.expectedError) }) } }) t.Run("with valid request should return 202", func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) rc := createRequestCtx() response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) require.Equal(t, http.StatusAccepted, response.Status()) }) t.Run("with disabled recording rules", func(t *testing.T) { testCases := []struct { name string recordingRules bool expectedStatus int }{ { name: "when recording rules are enabled", recordingRules: true, expectedStatus: http.StatusAccepted, }, { name: "when recording rules are disabled", recordingRules: false, expectedStatus: http.StatusBadRequest, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { features := featuremgmt.WithFeatures() srv, _, _ := createConvertPrometheusSrv(t, withFeatureToggles(features)) srv.cfg.RecordingRules.Enabled = tc.recordingRules rc := createRequestCtx() response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) require.Equal(t, tc.expectedStatus, response.Status()) }) } }) t.Run("with disable provenance header should use ProvenanceNone", func(t *testing.T) { provenanceStore := fakes.NewFakeProvisioningStore() folderService := foldertest.NewFakeService() srv, _, ruleStore := createConvertPrometheusSrv(t, withProvenanceStore(provenanceStore), withFolderService(folderService)) // Create a folder in the root fldr := randFolder() fldr.ParentUID = "" folderService.ExpectedFolder = fldr folderService.ExpectedFolders = []*folder.Folder{fldr} ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr) // Create request with the X-Disable-Provenance header rc := createRequestCtx() rc.Req.Header.Set("X-Disable-Provenance", "true") response := srv.RouteConvertPrometheusPostRuleGroup(rc, fldr.Title, simpleGroup) require.Equal(t, http.StatusAccepted, response.Status()) // Get the created rules rules, err := ruleStore.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{ OrgID: 1, }) require.NoError(t, err) require.Len(t, rules, 2) // Verify provenance was set to ProvenanceNone for _, r := range rules { prov, err := provenanceStore.GetProvenance(context.Background(), r, 1) require.NoError(t, err) require.Equal(t, models.ProvenanceNone, prov, "Provenance should be ProvenanceNone when X-Disable-Provenance header is set") // Prometheus rule definition should not be saved when provenance is disabled require.Nil(t, r.Metadata.PrometheusStyleRule) } }) t.Run("returns error when target datasource does not exist", func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) rc := createRequestCtx() rc.Req.Header.Set(targetDatasourceUIDHeader, "some-data-source") response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) require.Equal(t, http.StatusNotFound, response.Status()) require.Contains(t, string(response.Body()), "failed to get recording rules target datasource") }) t.Run("uses target datasource for recording rules", func(t *testing.T) { srv, dsCache, ruleStore := createConvertPrometheusSrv(t) rc := createRequestCtx() targetDSUID := util.GenerateShortUID() ds := &datasources.DataSource{ UID: targetDSUID, Type: datasources.DS_PROMETHEUS, } dsCache.DataSources = append(dsCache.DataSources, ds) rc.Req.Header.Set(targetDatasourceUIDHeader, targetDSUID) simpleGroup := apimodels.PrometheusRuleGroup{ Name: "Test Group", Interval: prommodel.Duration(1 * time.Minute), Rules: []apimodels.PrometheusRule{ { Record: "recorded-metric", Expr: "vector(1)", Labels: map[string]string{ "severity": "warning", }, }, }, } response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) require.Equal(t, http.StatusAccepted, response.Status()) remaining, err := ruleStore.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{ OrgID: 1, }) require.NoError(t, err) require.Len(t, remaining, 1) require.NotNil(t, remaining[0].Record) require.Equal(t, targetDSUID, remaining[0].Record.TargetDatasourceUID) }) t.Run("sets notification settings for rules if specified", func(t *testing.T) { srv, _, ruleStore := createConvertPrometheusSrv(t) rc := createRequestCtx() receiver := "test-receiver" groupBy := []string{"cluster", "pod"} settings := apimodels.AlertRuleNotificationSettings{ Receiver: receiver, GroupBy: groupBy, } settingsJSON, err := json.Marshal(settings) require.NoError(t, err) rc.Req.Header.Set(notificationSettingsHeader, string(settingsJSON)) simpleGroup := apimodels.PrometheusRuleGroup{ Name: "Test Group", Interval: prommodel.Duration(1 * time.Minute), Rules: []apimodels.PrometheusRule{ { Alert: "TestAlert", Expr: "up == 0", For: util.Pointer(prommodel.Duration(5 * time.Minute)), Labels: map[string]string{ "severity": "critical", }, }, }, } response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) require.Equal(t, http.StatusAccepted, response.Status()) createdRules, err := ruleStore.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{ OrgID: 1, }) require.NoError(t, err) require.Len(t, createdRules, 1) require.Len(t, createdRules[0].NotificationSettings, 1) require.Equal(t, receiver, createdRules[0].NotificationSettings[0].Receiver) require.Equal(t, groupBy, createdRules[0].NotificationSettings[0].GroupBy) }) t.Run("returns error when notification settings header contains invalid JSON", func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) rc := createRequestCtx() rc.Req.Header.Set(notificationSettingsHeader, "{invalid json") simpleGroup := apimodels.PrometheusRuleGroup{ Name: "Test Group", Interval: prommodel.Duration(1 * time.Minute), Rules: []apimodels.PrometheusRule{ { Alert: "TestAlert", Expr: "up == 0", }, }, } response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) require.Equal(t, http.StatusBadRequest, response.Status()) require.Contains(t, string(response.Body()), "Invalid value for header X-Grafana-Alerting-Notification-Settings") }) t.Run("returns error when notification settings contain invalid values", func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) rc := createRequestCtx() settings := apimodels.AlertRuleNotificationSettings{ Receiver: "", // empty receiver is invalid } settingsJSON, err := json.Marshal(settings) require.NoError(t, err) rc.Req.Header.Set(notificationSettingsHeader, string(settingsJSON)) simpleGroup := apimodels.PrometheusRuleGroup{ Name: "Test Group", Interval: prommodel.Duration(1 * time.Minute), Rules: []apimodels.PrometheusRule{ { Alert: "TestAlert", Expr: "up == 0", }, }, } response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) require.Equal(t, http.StatusBadRequest, response.Status()) require.Contains(t, string(response.Body()), "Invalid value for header X-Grafana-Alerting-Notification-Settings") }) } func TestRouteConvertPrometheusGetRuleGroup(t *testing.T) { promRule := apimodels.PrometheusRule{ Alert: "test alert", Expr: "vector(1) > 0", For: util.Pointer(prommodel.Duration(5 * time.Minute)), Labels: map[string]string{ "severity": "critical", }, Annotations: map[string]string{ "summary": "test alert", }, } promRuleYAML, err := yaml.Marshal(promRule) require.NoError(t, err) t.Run("with non-existent folder should return 404", func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) rc := createRequestCtx() response := srv.RouteConvertPrometheusGetRuleGroup(rc, "non-existent", "test") require.Equal(t, http.StatusNotFound, response.Status(), string(response.Body())) }) t.Run("with non-existent group should return 404", func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) rc := createRequestCtx() response := srv.RouteConvertPrometheusGetRuleGroup(rc, "test", "non-existent") require.Equal(t, http.StatusNotFound, response.Status(), string(response.Body())) }) t.Run("with valid request should return 200", func(t *testing.T) { folderService := foldertest.NewFakeService() srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService)) rc := createRequestCtx() // Create two folders in the root folder fldr := randFolder() fldr.ParentUID = "" folderService.ExpectedFolder = fldr folderService.ExpectedFolders = []*folder.Folder{fldr} ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr) // Create rules in both folders groupKey := models.GenerateGroupKey(rc.OrgID) groupKey.NamespaceUID = fldr.UID groupKey.RuleGroup = "test-group" rule := models.RuleGen. With(models.RuleGen.WithGroupKey(groupKey)). With(models.RuleGen.WithTitle("TestAlert")). With(models.RuleGen.WithIntervalSeconds(60)). With(models.RuleGen.WithPrometheusOriginalRuleDefinition(string(promRuleYAML))). GenerateRef() ruleStore.PutRule(context.Background(), rule) // Create a rule in another group groupKeyNotFromProm := models.GenerateGroupKey(rc.OrgID) groupKeyNotFromProm.NamespaceUID = fldr.UID groupKeyNotFromProm.RuleGroup = "test-group-2" ruleInOtherFolder := models.RuleGen. With(models.RuleGen.WithGroupKey(groupKeyNotFromProm)). With(models.RuleGen.WithTitle("in another group")). With(models.RuleGen.WithIntervalSeconds(60)). GenerateRef() ruleStore.PutRule(context.Background(), ruleInOtherFolder) getResp := srv.RouteConvertPrometheusGetRuleGroup(rc, fldr.Title, groupKey.RuleGroup) require.Equal(t, http.StatusOK, getResp.Status()) var respGroup apimodels.PrometheusRuleGroup err := yaml.Unmarshal(getResp.Body(), &respGroup) require.NoError(t, err) require.Equal(t, groupKey.RuleGroup, respGroup.Name) require.Equal(t, prommodel.Duration(time.Duration(rule.IntervalSeconds)*time.Second), respGroup.Interval) require.Len(t, respGroup.Rules, 1) require.Equal(t, promRule.Alert, respGroup.Rules[0].Alert) }) } func TestRouteConvertPrometheusGetNamespace(t *testing.T) { promRule1 := apimodels.PrometheusRule{ Alert: "test alert", Expr: "vector(1) > 0", For: util.Pointer(prommodel.Duration(5 * time.Minute)), Labels: map[string]string{ "severity": "critical", }, Annotations: map[string]string{ "summary": "test alert", }, } promRule2 := apimodels.PrometheusRule{ Alert: "test alert 2", Expr: "vector(1) > 0", For: util.Pointer(prommodel.Duration(5 * time.Minute)), Labels: map[string]string{ "severity": "also critical", }, Annotations: map[string]string{ "summary": "test alert 2", }, } promGroup1 := apimodels.PrometheusRuleGroup{ Name: "Test Group", Interval: prommodel.Duration(1 * time.Minute), Rules: []apimodels.PrometheusRule{ promRule1, }, } promGroup2 := apimodels.PrometheusRuleGroup{ Name: "Test Group 2", Interval: prommodel.Duration(1 * time.Minute), Rules: []apimodels.PrometheusRule{ promRule2, }, } t.Run("with non-existent folder should return 404", func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) rc := createRequestCtx() response := srv.RouteConvertPrometheusGetNamespace(rc, "non-existent") require.Equal(t, http.StatusNotFound, response.Status()) }) t.Run("with valid request should return 200", func(t *testing.T) { folderService := foldertest.NewFakeService() srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService)) rc := createRequestCtx() // Create two folders in the root folder fldr := randFolder() fldr.ParentUID = "" fldr2 := randFolder() fldr2.ParentUID = "" folderService.ExpectedFolders = []*folder.Folder{fldr, fldr2} ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr, fldr2) // Create a Grafana rule for each Prometheus rule for _, promGroup := range []apimodels.PrometheusRuleGroup{promGroup1, promGroup2} { groupKey := models.GenerateGroupKey(rc.OrgID) groupKey.NamespaceUID = fldr.UID groupKey.RuleGroup = promGroup.Name promRuleYAML, err := yaml.Marshal(promGroup.Rules[0]) require.NoError(t, err) rule := models.RuleGen. With(models.RuleGen.WithGroupKey(groupKey)). With(models.RuleGen.WithTitle(promGroup.Rules[0].Alert)). With(models.RuleGen.WithIntervalSeconds(60)). With(models.RuleGen.WithPrometheusOriginalRuleDefinition(string(promRuleYAML))). GenerateRef() ruleStore.PutRule(context.Background(), rule) } response := srv.RouteConvertPrometheusGetNamespace(rc, fldr.Title) require.Equal(t, http.StatusOK, response.Status()) var respNamespaces map[string][]apimodels.PrometheusRuleGroup err := yaml.Unmarshal(response.Body(), &respNamespaces) require.NoError(t, err) require.Len(t, respNamespaces, 1) require.Contains(t, respNamespaces, fldr.Title) require.ElementsMatch(t, respNamespaces[fldr.Title], []apimodels.PrometheusRuleGroup{promGroup1, promGroup2}) }) } func TestRouteConvertPrometheusGetRules(t *testing.T) { promRule1 := apimodels.PrometheusRule{ Alert: "test alert", Expr: "vector(1) > 0", For: util.Pointer(prommodel.Duration(5 * time.Minute)), Labels: map[string]string{ "severity": "critical", }, Annotations: map[string]string{ "summary": "test alert", }, } promRule2 := apimodels.PrometheusRule{ Alert: "test alert 2", Expr: "vector(1) > 0", For: util.Pointer(prommodel.Duration(5 * time.Minute)), Labels: map[string]string{ "severity": "also critical", }, Annotations: map[string]string{ "summary": "test alert 2", }, } promGroup1 := apimodels.PrometheusRuleGroup{ Name: "Test Group", Interval: prommodel.Duration(1 * time.Minute), Rules: []apimodels.PrometheusRule{ promRule1, }, } promGroup2 := apimodels.PrometheusRuleGroup{ Name: "Test Group 2", Interval: prommodel.Duration(1 * time.Minute), Rules: []apimodels.PrometheusRule{ promRule2, }, } assertEmptyResponse := func(t *testing.T, srv *ConvertPrometheusSrv, reqCtx *contextmodel.ReqContext) { t.Helper() response := srv.RouteConvertPrometheusGetRules(reqCtx) require.Equal(t, http.StatusOK, response.Status()) var respNamespaces map[string][]apimodels.PrometheusRuleGroup err := yaml.Unmarshal(response.Body(), &respNamespaces) require.NoError(t, err) require.Empty(t, respNamespaces) } // testForEmptyResponses tests that RouteConvertPrometheusGetRules returns an empty response // when there are no rules in the folder or the folder does not exist. testForEmptyResponses := func(t *testing.T, withCustomFolderHeader bool) { rc := createRequestCtx() unknownFolderUID := "some unknown folder" rootFolderUID := "" if withCustomFolderHeader { rootFolderUID = unknownFolderUID rc.Req.Header.Set(folderUIDHeader, unknownFolderUID) } t.Run("for non-existent folder should return empty response", func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) assertEmptyResponse(t, srv, rc) }) t.Run("for existing folder with no children should return empty response", func(t *testing.T) { folderService := foldertest.NewFakeService() srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService)) fldr := randFolder() fldr.UID = unknownFolderUID fldr.ParentUID = rootFolderUID folderService.ExpectedFolders = []*folder.Folder{fldr} ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr) assertEmptyResponse(t, srv, rc) }) } t.Run("without custom root folder", func(t *testing.T) { testForEmptyResponses(t, false) }) t.Run("with custom root folder", func(t *testing.T) { testForEmptyResponses(t, true) }) t.Run("with rules should return 200 with rules", func(t *testing.T) { folderService := foldertest.NewFakeService() srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService)) rc := createRequestCtx() // Create a folder in the root fldr := randFolder() fldr.ParentUID = "" folderService.ExpectedFolders = []*folder.Folder{fldr} ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr) // Create a Grafana rule for each Prometheus rule for _, promGroup := range []apimodels.PrometheusRuleGroup{promGroup1, promGroup2} { groupKey := models.GenerateGroupKey(rc.OrgID) groupKey.NamespaceUID = fldr.UID groupKey.RuleGroup = promGroup.Name promRuleYAML, err := yaml.Marshal(promGroup.Rules[0]) require.NoError(t, err) rule := models.RuleGen. With(models.RuleGen.WithGroupKey(groupKey)). With(models.RuleGen.WithTitle(promGroup.Rules[0].Alert)). With(models.RuleGen.WithIntervalSeconds(60)). With(models.RuleGen.WithPrometheusOriginalRuleDefinition(string(promRuleYAML))). GenerateRef() ruleStore.PutRule(context.Background(), rule) } response := srv.RouteConvertPrometheusGetRules(rc) require.Equal(t, http.StatusOK, response.Status()) var respNamespaces map[string][]apimodels.PrometheusRuleGroup err := yaml.Unmarshal(response.Body(), &respNamespaces) require.NoError(t, err) require.Len(t, respNamespaces, 1) require.Contains(t, respNamespaces, fldr.Title) require.ElementsMatch(t, respNamespaces[fldr.Title], []apimodels.PrometheusRuleGroup{promGroup1, promGroup2}) }) } func TestRouteConvertPrometheusDeleteNamespace(t *testing.T) { t.Run("for non-existent folder should return 404", func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) rc := createRequestCtx() response := srv.RouteConvertPrometheusDeleteNamespace(rc, "non-existent") require.Equal(t, http.StatusNotFound, response.Status()) }) t.Run("for existing folder with no groups should return 404", func(t *testing.T) { folderService := foldertest.NewFakeService() srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService)) rc := createRequestCtx() fldr := randFolder() fldr.ParentUID = "" folderService.ExpectedFolder = fldr folderService.ExpectedFolders = []*folder.Folder{fldr} ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr) response := srv.RouteConvertPrometheusDeleteNamespace(rc, "non-existent") require.Equal(t, http.StatusNotFound, response.Status()) }) t.Run("valid request should delete rules", func(t *testing.T) { initNamespace := func(promDefinition string, opts ...convertPrometheusSrvOptionsFunc) (*ConvertPrometheusSrv, *fakes.RuleStore, *folder.Folder, *models.AlertRule) { folderService := foldertest.NewFakeService() srv, _, ruleStore := createConvertPrometheusSrv(t, append(opts, withFolderService(folderService))...) // Create a folder in the root fldr := randFolder() fldr.ParentUID = "" folderService.ExpectedFolder = fldr folderService.ExpectedFolders = []*folder.Folder{fldr} ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr) rule := models.RuleGen. With(models.RuleGen.WithNamespaceUID(fldr.UID)). With(models.RuleGen.WithOrgID(1)). With(models.RuleGen.WithPrometheusOriginalRuleDefinition(promDefinition)). GenerateRef() ruleStore.PutRule(context.Background(), rule) return srv, ruleStore, fldr, rule } t.Run("valid request should delete rules", func(t *testing.T) { srv, ruleStore, fldr, rule := initNamespace("prometheus definition") // Create another rule group in a different namespace that should not be deleted otherGroupName := "other-group" otherRule := models.RuleGen. With(models.RuleGen.WithOrgID(1)). With(models.RuleGen.WithGroupName(otherGroupName)). With(models.RuleGen.WithPrometheusOriginalRuleDefinition("other prometheus definition")). GenerateRef() ruleStore.PutRule(context.Background(), otherRule) rc := createRequestCtx() response := srv.RouteConvertPrometheusDeleteNamespace(rc, fldr.Title) require.Equal(t, http.StatusAccepted, response.Status()) // Verify the rule in the specified group was deleted remaining, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{ UID: rule.UID, OrgID: rule.OrgID, }) require.Error(t, err) require.Nil(t, remaining) // Verify the rule in the other group still exists remainingOther, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{ UID: otherRule.UID, OrgID: otherRule.OrgID, }) require.NoError(t, err) require.NotNil(t, remainingOther) }) t.Run("fails to delete rules when they are provisioned", func(t *testing.T) { provenanceStore := fakes.NewFakeProvisioningStore() srv, ruleStore, fldr, rule := initNamespace("", withProvenanceStore(provenanceStore)) rc := createRequestCtx() // Create a provisioned rule rule2 := models.RuleGen. With(models.RuleGen.WithNamespaceUID(fldr.UID)). With(models.RuleGen.WithOrgID(1)). With(models.RuleGen.WithPrometheusOriginalRuleDefinition("prometheus definition")). GenerateRef() ruleStore.PutRule(context.Background(), rule2) err := provenanceStore.SetProvenance(context.Background(), rule2, 1, models.ProvenanceAPI) require.NoError(t, err) response := srv.RouteConvertPrometheusDeleteNamespace(rc, fldr.Title) require.Equal(t, http.StatusConflict, response.Status()) // Verify the rule is still present remaining, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{ UID: rule.UID, OrgID: rule.OrgID, }) require.NoError(t, err) require.NotNil(t, remaining) }) t.Run("with disable provenance header should still be able to delete rules", func(t *testing.T) { provenanceStore := fakes.NewFakeProvisioningStore() srv, ruleStore, fldr, rule := initNamespace("prometheus definition", withProvenanceStore(provenanceStore)) // Mark the rule as provisioned with API provenance err := provenanceStore.SetProvenance(context.Background(), rule, 1, models.ProvenanceConvertedPrometheus) require.NoError(t, err) rc := createRequestCtx() rc.Req.Header.Set("X-Disable-Provenance", "true") response := srv.RouteConvertPrometheusDeleteNamespace(rc, fldr.Title) require.Equal(t, http.StatusAccepted, response.Status()) // Verify the rule was deleted remaining, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{ UID: rule.UID, OrgID: rule.OrgID, }) require.Error(t, err) require.Nil(t, remaining) }) }) } func TestRouteConvertPrometheusDeleteRuleGroup(t *testing.T) { t.Run("for non-existent folder should return 404", func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) rc := createRequestCtx() response := srv.RouteConvertPrometheusDeleteRuleGroup(rc, "non-existent", "test-group") require.Equal(t, http.StatusNotFound, response.Status()) }) t.Run("for existing folder with no group should return 404", func(t *testing.T) { folderService := foldertest.NewFakeService() srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService)) rc := createRequestCtx() fldr := randFolder() fldr.ParentUID = "" folderService.ExpectedFolder = fldr folderService.ExpectedFolders = []*folder.Folder{fldr} ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr) response := srv.RouteConvertPrometheusDeleteRuleGroup(rc, fldr.Title, "test-group") require.Equal(t, http.StatusNotFound, response.Status()) }) const groupName = "test-group" t.Run("valid request should delete rules", func(t *testing.T) { initGroup := func(promDefinition string, groupName string, opts ...convertPrometheusSrvOptionsFunc) (*ConvertPrometheusSrv, *fakes.RuleStore, *folder.Folder, *models.AlertRule) { folderService := foldertest.NewFakeService() srv, _, ruleStore := createConvertPrometheusSrv(t, append(opts, withFolderService(folderService))...) // Create a folder in the root fldr := randFolder() fldr.ParentUID = "" folderService.ExpectedFolder = fldr folderService.ExpectedFolders = []*folder.Folder{fldr} ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr) rule := models.RuleGen. With(models.RuleGen.WithNamespaceUID(fldr.UID)). With(models.RuleGen.WithOrgID(1)). With(models.RuleGen.WithGroupName(groupName)). With(models.RuleGen.WithPrometheusOriginalRuleDefinition(promDefinition)). GenerateRef() ruleStore.PutRule(context.Background(), rule) return srv, ruleStore, fldr, rule } t.Run("valid request should delete rules", func(t *testing.T) { srv, ruleStore, fldr, rule := initGroup("prometheus definition", groupName) rc := createRequestCtx() // Create another rule in a different group that should not be deleted otherGroupName := "other-group" otherRule := models.RuleGen. With(models.RuleGen.WithNamespaceUID(fldr.UID)). With(models.RuleGen.WithOrgID(1)). With(models.RuleGen.WithGroupName(otherGroupName)). With(models.RuleGen.WithPrometheusOriginalRuleDefinition("other prometheus definition")). GenerateRef() ruleStore.PutRule(context.Background(), otherRule) response := srv.RouteConvertPrometheusDeleteRuleGroup(rc, fldr.Title, groupName) require.Equal(t, http.StatusAccepted, response.Status()) // Verify the rule was deleted remaining, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{ UID: rule.UID, OrgID: rule.OrgID, }) require.Error(t, err) require.Nil(t, remaining) // Verify the otherRule from the "other-group" is still present otherRuleRefreshed, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{ UID: otherRule.UID, OrgID: otherRule.OrgID, }) require.NoError(t, err) require.NotNil(t, otherRuleRefreshed) }) t.Run("fails to delete rules when they are provisioned", func(t *testing.T) { provenanceStore := fakes.NewFakeProvisioningStore() srv, ruleStore, fldr, rule := initGroup("", groupName, withProvenanceStore(provenanceStore)) rc := createRequestCtx() // Create a provisioned rule rule2 := models.RuleGen. With(models.RuleGen.WithNamespaceUID(fldr.UID)). With(models.RuleGen.WithOrgID(1)). With(models.RuleGen.WithGroupName(groupName)). With(models.RuleGen.WithPrometheusOriginalRuleDefinition("prometheus definition")). GenerateRef() ruleStore.PutRule(context.Background(), rule2) err := provenanceStore.SetProvenance(context.Background(), rule2, 1, models.ProvenanceAPI) require.NoError(t, err) response := srv.RouteConvertPrometheusDeleteRuleGroup(rc, fldr.Title, groupName) require.Equal(t, http.StatusConflict, response.Status()) // Verify the rule is still present remaining, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{ UID: rule.UID, OrgID: rule.OrgID, }) require.NoError(t, err) require.NotNil(t, remaining) }) t.Run("with disable provenance header should still be able to delete rules", func(t *testing.T) { provenanceStore := fakes.NewFakeProvisioningStore() srv, ruleStore, fldr, rule := initGroup("", groupName, withProvenanceStore(provenanceStore)) // Mark the rule as provisioned with API provenance err := provenanceStore.SetProvenance(context.Background(), rule, 1, models.ProvenanceConvertedPrometheus) require.NoError(t, err) rc := createRequestCtx() rc.Req.Header.Set("X-Disable-Provenance", "true") response := srv.RouteConvertPrometheusDeleteRuleGroup(rc, fldr.Title, groupName) require.Equal(t, http.StatusAccepted, response.Status()) // Verify the rule was deleted remaining, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{ UID: rule.UID, OrgID: rule.OrgID, }) require.Error(t, err) require.Nil(t, remaining) }) }) } func TestRouteConvertPrometheusPostRuleGroups(t *testing.T) { folderService := foldertest.NewFakeService() srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService)) req := createRequestCtx() req.Req.Header.Set(datasourceUIDHeader, existingDSUID) // Create test prometheus rules promAlertRule := apimodels.PrometheusRule{ Alert: "TestAlert", Expr: "up == 0", For: util.Pointer(prommodel.Duration(5 * time.Minute)), Labels: map[string]string{ "severity": "critical", }, } promRecordingRule := apimodels.PrometheusRule{ Record: "TestRecordingRule", Expr: "up == 0", } promGroup1 := apimodels.PrometheusRuleGroup{ Name: "TestGroup1", Interval: prommodel.Duration(1 * time.Minute), Rules: []apimodels.PrometheusRule{promAlertRule}, Labels: map[string]string{ "group_label": "group_value", }, } queryOffset := prommodel.Duration(5 * time.Minute) promGroup2 := apimodels.PrometheusRuleGroup{ Name: "TestGroup2", Interval: prommodel.Duration(1 * time.Minute), Rules: []apimodels.PrometheusRule{promAlertRule}, QueryOffset: &queryOffset, } promGroup3 := apimodels.PrometheusRuleGroup{ Name: "TestGroup3", Interval: prommodel.Duration(1 * time.Minute), Rules: []apimodels.PrometheusRule{promAlertRule, promRecordingRule}, } promGroups := map[string][]apimodels.PrometheusRuleGroup{ "namespace1": {promGroup1, promGroup2}, "namespace2": {promGroup3}, } t.Run("should convert prometheus rules to Grafana rules", func(t *testing.T) { // Call the endpoint response := srv.RouteConvertPrometheusPostRuleGroups(req, promGroups) require.Equal(t, http.StatusAccepted, response.Status()) // Verify the rules were created rules, err := ruleStore.ListAlertRules(req.Req.Context(), &models.ListAlertRulesQuery{ OrgID: req.GetOrgID(), }) require.NoError(t, err) require.Len(t, rules, 4) // Verify rule content for _, rule := range rules { require.Equal(t, int64(60), rule.IntervalSeconds) // 1 minute interval // Check that the rule matches one of our original prometheus rules switch rule.RuleGroup { case "TestGroup1": require.Equal(t, "TestAlert", rule.Title) require.Equal(t, "critical", rule.Labels["severity"]) require.Equal(t, 5*time.Minute, rule.For) require.Equal(t, "group_value", rule.Labels["group_label"]) case "TestGroup2": require.Equal(t, "TestAlert", rule.Title) require.Equal(t, "critical", rule.Labels["severity"]) require.Equal(t, 5*time.Minute, rule.For) require.Equal(t, models.Duration(queryOffset), rule.Data[0].RelativeTimeRange.To) case "TestGroup3": switch rule.Title { case "TestAlert": require.Equal(t, "critical", rule.Labels["severity"]) require.Equal(t, 5*time.Minute, rule.For) case "TestRecordingRule": require.Equal(t, "TestRecordingRule", rule.Record.Metric) default: t.Fatalf("unexpected rule title: %s", rule.Title) } default: t.Fatalf("unexpected rule group: %s", rule.RuleGroup) } } }) t.Run("should convert Prometheus rules to Grafana rules but pause recording rules", func(t *testing.T) { clear(ruleStore.Rules) req.Req.Header.Set(alertRulesPausedHeader, "false") req.Req.Header.Set(recordingRulesPausedHeader, "true") // Call the endpoint response := srv.RouteConvertPrometheusPostRuleGroups(req, promGroups) require.Equal(t, http.StatusAccepted, response.Status()) // Verify the rules were created rules, err := ruleStore.ListAlertRules(req.Req.Context(), &models.ListAlertRulesQuery{ OrgID: req.GetOrgID(), }) require.NoError(t, err) require.Len(t, rules, 4) // Verify the recording rule is paused for _, rule := range rules { if rule.Record != nil { require.True(t, rule.IsPaused) } } }) t.Run("should convert Prometheus rules to Grafana rules but pause alert rules", func(t *testing.T) { clear(ruleStore.Rules) req.Req.Header.Set(alertRulesPausedHeader, "true") req.Req.Header.Set(recordingRulesPausedHeader, "false") // Call the endpoint response := srv.RouteConvertPrometheusPostRuleGroups(req, promGroups) require.Equal(t, http.StatusAccepted, response.Status()) // Verify the rules were created rules, err := ruleStore.ListAlertRules(req.Req.Context(), &models.ListAlertRulesQuery{ OrgID: req.GetOrgID(), }) require.NoError(t, err) require.Len(t, rules, 4) // Verify the alert rule is paused for _, rule := range rules { if rule.Record == nil { require.True(t, rule.IsPaused) } } }) t.Run("should convert Prometheus rules to Grafana rules but pause both alert and recording rules", func(t *testing.T) { clear(ruleStore.Rules) req.Req.Header.Set(recordingRulesPausedHeader, "true") req.Req.Header.Set(alertRulesPausedHeader, "true") // Call the endpoint response := srv.RouteConvertPrometheusPostRuleGroups(req, promGroups) require.Equal(t, http.StatusAccepted, response.Status()) // Verify the rules were created rules, err := ruleStore.ListAlertRules(req.Req.Context(), &models.ListAlertRulesQuery{ OrgID: req.GetOrgID(), }) require.NoError(t, err) require.Len(t, rules, 4) // Verify the alert rule is paused for _, rule := range rules { require.True(t, rule.IsPaused) } }) t.Run("convert Prometheus rules to Grafana rules into a specified target folder", func(t *testing.T) { clear(ruleStore.Rules) // Create a target folder to move the rules into fldr := randFolder() fldr.ParentUID = "" folderService.ExpectedFolder = fldr folderService.ExpectedFolders = []*folder.Folder{fldr} ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr) req.Req.Header.Del(recordingRulesPausedHeader) req.Req.Header.Del(alertRulesPausedHeader) req.Req.Header.Set(folderUIDHeader, fldr.UID) // Call the endpoint response := srv.RouteConvertPrometheusPostRuleGroups(req, promGroups) require.Equal(t, http.StatusAccepted, response.Status()) // Verify the rules were created rules, err := ruleStore.ListAlertRules(req.Req.Context(), &models.ListAlertRulesQuery{ OrgID: req.GetOrgID(), }) require.NoError(t, err) require.Len(t, rules, 4) for _, rule := range rules { parentFolders, err := folderService.GetParents(context.Background(), folder.GetParentsQuery{UID: rule.NamespaceUID, OrgID: 1}) require.NoError(t, err) require.Len(t, parentFolders, 1) require.Equal(t, fldr.UID, parentFolders[0].UID) } }) } type convertPrometheusSrvOptions struct { provenanceStore provisioning.ProvisioningStore fakeAccessControlRuleService *acfakes.FakeRuleService quotaChecker *provisioning.MockQuotaChecker featureToggles featuremgmt.FeatureToggles alertmanager Alertmanager folderService folder.Service } type convertPrometheusSrvOptionsFunc func(*convertPrometheusSrvOptions) func withProvenanceStore(store provisioning.ProvisioningStore) convertPrometheusSrvOptionsFunc { return func(opts *convertPrometheusSrvOptions) { opts.provenanceStore = store } } func withFakeAccessControlRuleService(service *acfakes.FakeRuleService) convertPrometheusSrvOptionsFunc { return func(opts *convertPrometheusSrvOptions) { opts.fakeAccessControlRuleService = service } } func withQuotaChecker(checker *provisioning.MockQuotaChecker) convertPrometheusSrvOptionsFunc { return func(opts *convertPrometheusSrvOptions) { opts.quotaChecker = checker } } func withFeatureToggles(toggles featuremgmt.FeatureToggles) convertPrometheusSrvOptionsFunc { return func(opts *convertPrometheusSrvOptions) { opts.featureToggles = toggles } } func withAlertmanager(am Alertmanager) convertPrometheusSrvOptionsFunc { return func(opts *convertPrometheusSrvOptions) { opts.alertmanager = am } } func withFolderService(f folder.Service) convertPrometheusSrvOptionsFunc { return func(opts *convertPrometheusSrvOptions) { opts.folderService = f } } func createConvertPrometheusSrv(t *testing.T, opts ...convertPrometheusSrvOptionsFunc) (*ConvertPrometheusSrv, *dsfakes.FakeCacheService, *fakes.RuleStore) { t.Helper() // By default the quota checker will allow the operation quotas := &provisioning.MockQuotaChecker{} quotas.EXPECT().LimitOK() options := convertPrometheusSrvOptions{ provenanceStore: fakes.NewFakeProvisioningStore(), fakeAccessControlRuleService: &acfakes.FakeRuleService{}, quotaChecker: quotas, folderService: foldertest.NewFakeService(), } for _, opt := range opts { opt(&options) } ruleStore := fakes.NewRuleStore(t) folder := randFolder() ruleStore.Folders[1] = append(ruleStore.Folders[1], folder) dsCache := &dsfakes.FakeCacheService{} ds := &datasources.DataSource{ UID: existingDSUID, Type: datasources.DS_PROMETHEUS, } dsCache.DataSources = append(dsCache.DataSources, ds) alertRuleService := provisioning.NewAlertRuleService( ruleStore, options.provenanceStore, options.folderService, options.quotaChecker, &provisioning.NopTransactionManager{}, 60, 10, 100, log.New("test"), &provisioning.NotificationSettingsValidatorProviderFake{}, options.fakeAccessControlRuleService, ) cfg := &setting.UnifiedAlertingSettings{ DefaultRuleEvaluationInterval: 1 * time.Minute, RecordingRules: setting.RecordingRuleSettings{ Enabled: true, }, } srv := NewConvertPrometheusSrv(cfg, log.NewNopLogger(), ruleStore, dsCache, alertRuleService, options.featureToggles, options.alertmanager) return srv, dsCache, ruleStore } func createRequestCtx() *contextmodel.ReqContext { req := httptest.NewRequest("GET", "http://localhost", nil) req.Header.Set(datasourceUIDHeader, existingDSUID) return &contextmodel.ReqContext{ Context: &web.Context{ Req: req, Resp: web.NewResponseWriter("GET", httptest.NewRecorder()), }, SignedInUser: &user.SignedInUser{OrgID: 1}, } } // Test parseBooleanHeader function which handles boolean header values func TestParseBooleanHeader(t *testing.T) { headerName := "X-Test-Header" t.Run("should return false when header is not present", func(t *testing.T) { result, err := parseBooleanHeader("", headerName) require.NoError(t, err) require.False(t, result) }) t.Run("should return true when header is 'true'", func(t *testing.T) { result, err := parseBooleanHeader("true", headerName) require.NoError(t, err) require.True(t, result) }) t.Run("should return false when header is 'false'", func(t *testing.T) { result, err := parseBooleanHeader("false", headerName) require.NoError(t, err) require.False(t, result) }) t.Run("should return true when header is 'TRUE' (case insensitive)", func(t *testing.T) { result, err := parseBooleanHeader("TRUE", headerName) require.NoError(t, err) require.True(t, result) }) t.Run("should return error when header has invalid value", func(t *testing.T) { _, err := parseBooleanHeader("invalid", headerName) require.Error(t, err) require.ErrorContains(t, err, "Invalid value for header") }) t.Run("should return error when header is numeric but not 0/1", func(t *testing.T) { _, err := parseBooleanHeader("2", headerName) require.Error(t, err) }) } func TestGetWorkingFolderUID(t *testing.T) { t.Run("should return root folder UID when header is not present", func(t *testing.T) { rc := createRequestCtx() rc.Req.Header.Del(folderUIDHeader) folderUID := getWorkingFolderUID(rc) require.Equal(t, folder.RootFolderUID, folderUID) }) t.Run("should return specified folder UID when header is present", func(t *testing.T) { rc := createRequestCtx() specifiedFolderUID := "specified-folder-uid" rc.Req.Header.Set(folderUIDHeader, specifiedFolderUID) folderUID := getWorkingFolderUID(rc) require.Equal(t, specifiedFolderUID, folderUID) }) t.Run("should return root folder UID when header is empty", func(t *testing.T) { rc := createRequestCtx() rc.Req.Header.Set(folderUIDHeader, "") folderUID := getWorkingFolderUID(rc) require.Equal(t, folder.RootFolderUID, folderUID) }) t.Run("should trim whitespace from header value", func(t *testing.T) { rc := createRequestCtx() specifiedFolderUID := "specified-folder-uid" rc.Req.Header.Set(folderUIDHeader, " "+specifiedFolderUID+" ") folderUID := getWorkingFolderUID(rc) require.Equal(t, specifiedFolderUID, folderUID) }) } func TestGetProvenance(t *testing.T) { t.Run("should return ProvenanceConvertedPrometheus when header is not present", func(t *testing.T) { rc := createRequestCtx() // Ensure the header is not present rc.Req.Header.Del(disableProvenanceHeaderName) provenance := getProvenance(rc) require.Equal(t, models.ProvenanceConvertedPrometheus, provenance) }) t.Run("should return ProvenanceNone when header is present", func(t *testing.T) { rc := createRequestCtx() // Set the disable provenance header rc.Req.Header.Set(disableProvenanceHeaderName, "true") provenance := getProvenance(rc) require.Equal(t, models.ProvenanceNone, provenance) }) t.Run("should return ProvenanceNone when header is present with any value", func(t *testing.T) { rc := createRequestCtx() // Set the disable provenance header with an empty value rc.Req.Header.Set(disableProvenanceHeaderName, "") provenance := getProvenance(rc) require.Equal(t, models.ProvenanceNone, provenance) }) } type mockAlertmanager struct { mock.Mock } func (m *mockAlertmanager) SaveAndApplyExtraConfiguration(ctx context.Context, org int64, extraConfig apimodels.ExtraConfiguration) error { args := m.Called(ctx, org, extraConfig) return args.Error(0) } func (m *mockAlertmanager) GetAlertmanagerConfiguration(ctx context.Context, org int64, withAutogen bool) (apimodels.GettableUserConfig, error) { args := m.Called(ctx, org, withAutogen) return args.Get(0).(apimodels.GettableUserConfig), args.Error(1) } func (m *mockAlertmanager) DeleteExtraConfiguration(ctx context.Context, org int64, identifier string) error { args := m.Called(ctx, org, identifier) return args.Error(0) } func TestRouteConvertPrometheusPostAlertmanagerConfig(t *testing.T) { const identifier = "test-config" mockAM := &mockAlertmanager{} ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI) srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft)) t.Run("should parse headers and call SaveAndApplyExtraConfiguration", func(t *testing.T) { mockAM.On("SaveAndApplyExtraConfiguration", mock.Anything, int64(1), mock.MatchedBy(func(extraConfig apimodels.ExtraConfiguration) bool { return extraConfig.Identifier == identifier && len(extraConfig.MergeMatchers) == 2 && len(extraConfig.TemplateFiles) == 1 && extraConfig.TemplateFiles["test.tmpl"] == "{{ define \"test\" }}Hello{{ end }}" })).Return(nil).Once() rc := createRequestCtx() rc.Req.Header.Set(configIdentifierHeader, identifier) rc.Req.Header.Set(mergeMatchersHeader, "environment=production,team=backend") amCfg := apimodels.AlertmanagerUserConfig{ AlertmanagerConfig: `{ "route": { "receiver": "default" }, "receivers": [ { "name": "default" } ] }`, TemplateFiles: map[string]string{ "test.tmpl": "{{ define \"test\" }}Hello{{ end }}", }, } response := srv.RouteConvertPrometheusPostAlertmanagerConfig(rc, amCfg) require.Equal(t, http.StatusAccepted, response.Status()) mockAM.AssertExpectations(t) }) t.Run("should use default identifier when header is missing", func(t *testing.T) { rc := createRequestCtx() rc.Req.Header.Set(mergeMatchersHeader, "test=value") mockAM := &mockAlertmanager{} mockAM.On("SaveAndApplyExtraConfiguration", mock.Anything, int64(1), mock.MatchedBy(func(extraConfig apimodels.ExtraConfiguration) bool { return extraConfig.Identifier == defaultConfigIdentifier })).Return(nil) ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI) srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft)) amCfg := apimodels.AlertmanagerUserConfig{} response := srv.RouteConvertPrometheusPostAlertmanagerConfig(rc, amCfg) require.Equal(t, http.StatusAccepted, response.Status()) mockAM.AssertExpectations(t) }) t.Run("should return error when merge matchers header has invalid format", func(t *testing.T) { rc := createRequestCtx() rc.Req.Header.Set(configIdentifierHeader, identifier) rc.Req.Header.Set(mergeMatchersHeader, "invalid-format") amCfg := apimodels.AlertmanagerUserConfig{} response := srv.RouteConvertPrometheusPostAlertmanagerConfig(rc, amCfg) require.Equal(t, http.StatusBadRequest, response.Status()) require.Contains(t, string(response.Body()), "format should be 'key=value,key2=value2'") }) } func TestRouteConvertPrometheusGetAlertmanagerConfig(t *testing.T) { const identifier = "test-config" const orgID = int64(1) t.Run("without feature flag should return 501", func(t *testing.T) { ft := featuremgmt.WithFeatures() srv, _, _ := createConvertPrometheusSrv(t, withFeatureToggles(ft)) rc := createRequestCtx() rc.Req.Header.Set(configIdentifierHeader, identifier) response := srv.RouteConvertPrometheusGetAlertmanagerConfig(rc) require.Equal(t, http.StatusNotImplemented, response.Status()) }) t.Run("without config identifier header should use default identifier", func(t *testing.T) { mockAM := &mockAlertmanager{} mockAM.On("GetAlertmanagerConfiguration", mock.Anything, orgID, false).Return(apimodels.GettableUserConfig{ ExtraConfigs: []apimodels.ExtraConfiguration{ { Identifier: defaultConfigIdentifier, AlertmanagerConfig: `route: receiver: default receivers: - name: default`, }, }, }, nil) ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI) srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft)) rc := createRequestCtx() response := srv.RouteConvertPrometheusGetAlertmanagerConfig(rc) require.Equal(t, http.StatusOK, response.Status()) mockAM.AssertExpectations(t) }) t.Run("with empty config identifier header should use default identifier", func(t *testing.T) { mockAM := &mockAlertmanager{} mockAM.On("GetAlertmanagerConfiguration", mock.Anything, orgID, false).Return(apimodels.GettableUserConfig{ ExtraConfigs: []apimodels.ExtraConfiguration{ { Identifier: defaultConfigIdentifier, AlertmanagerConfig: `route: receiver: default receivers: - name: default`, }, }, }, nil) ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI) srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft)) rc := createRequestCtx() rc.Req.Header.Set(configIdentifierHeader, "") response := srv.RouteConvertPrometheusGetAlertmanagerConfig(rc) require.Equal(t, http.StatusOK, response.Status()) mockAM.AssertExpectations(t) }) t.Run("should return config when it is found", func(t *testing.T) { mockAM := &mockAlertmanager{} ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI) srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft)) // Create a config with secrets to check that they will be hided in the response. expectedConfig := apimodels.GettableUserConfig{ ExtraConfigs: []apimodels.ExtraConfiguration{ { Identifier: identifier, TemplateFiles: map[string]string{ "test.tmpl": "{{ define \"test\" }}Hello{{ end }}", }, AlertmanagerConfig: `route: receiver: webhook receivers: - name: webhook webhook_configs: - url: "http://localhost/webhook" http_config: bearer_token: "some-token" `, }, }, } mockAM.On("GetAlertmanagerConfiguration", mock.Anything, int64(1), false).Return(expectedConfig, nil).Once() rc := createRequestCtx() rc.Req.Header.Set(configIdentifierHeader, identifier) response := srv.RouteConvertPrometheusGetAlertmanagerConfig(rc) require.Equal(t, http.StatusOK, response.Status()) expectedResponse := `alertmanager_config: | route: receiver: webhook continue: false receivers: - name: webhook webhook_configs: - send_resolved: true http_config: authorization: type: Bearer credentials: follow_redirects: true enable_http2: true url: url_file: "" max_alerts: 0 timeout: 0s templates: [] template_files: test.tmpl: '{{ define "test" }}Hello{{ end }}'` require.YAMLEq(t, expectedResponse, string(response.Body())) mockAM.AssertExpectations(t) }) t.Run("when config not found should return 404", func(t *testing.T) { mockAM := &mockAlertmanager{} ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI) srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft)) expectedConfig := apimodels.GettableUserConfig{ ExtraConfigs: []apimodels.ExtraConfiguration{ { Identifier: "other-config", TemplateFiles: map[string]string{ "test.tmpl": "{{ define \"test\" }}Hello{{ end }}", }, AlertmanagerConfig: `route: receiver: default receivers: - name: default`, }, }, } mockAM.On("GetAlertmanagerConfiguration", mock.Anything, orgID, false).Return(expectedConfig, nil).Once() rc := createRequestCtx() rc.Req.Header.Set(configIdentifierHeader, identifier) response := srv.RouteConvertPrometheusGetAlertmanagerConfig(rc) require.Equal(t, http.StatusNotFound, response.Status()) mockAM.AssertExpectations(t) }) t.Run("should return error when GetAlertmanagerConfiguration fails", func(t *testing.T) { mockAM := &mockAlertmanager{} ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI) srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft)) mockAM.On("GetAlertmanagerConfiguration", mock.Anything, orgID, false).Return(apimodels.GettableUserConfig{}, errors.New("config error")).Once() rc := createRequestCtx() rc.Req.Header.Set(configIdentifierHeader, identifier) response := srv.RouteConvertPrometheusGetAlertmanagerConfig(rc) require.Equal(t, http.StatusInternalServerError, response.Status()) mockAM.AssertExpectations(t) }) } func TestParseMergeMatchersHeader(t *testing.T) { testCases := []struct { name string headerValue string expectedError bool expectedMatchers amconfig.Matchers }{ { name: "empty header should return error", headerValue: "", expectedError: true, }, { name: "single matcher should parse correctly", headerValue: "env=prod", expectedError: false, expectedMatchers: amconfig.Matchers{ {Type: labels.MatchEqual, Name: "env", Value: "prod"}, }, }, { name: "multiple matchers should be parsed correctly", headerValue: "env=prod,team=alerting", expectedError: false, expectedMatchers: amconfig.Matchers{ {Type: labels.MatchEqual, Name: "env", Value: "prod"}, {Type: labels.MatchEqual, Name: "team", Value: "alerting"}, }, }, { name: "matchers with spaces should be parsed correctly", headerValue: " env = prod , team = alerting ", expectedError: false, expectedMatchers: amconfig.Matchers{ {Type: labels.MatchEqual, Name: "env", Value: "prod"}, {Type: labels.MatchEqual, Name: "team", Value: "alerting"}, }, }, { name: "invalid format without equals should return error", headerValue: "env:prod", expectedError: true, }, { name: "empty key should return error", headerValue: "=prod", expectedError: true, }, { name: "empty value should return error", headerValue: "env=", expectedError: true, }, { name: "missing value should return error", headerValue: "env", expectedError: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { rc := createRequestCtx() rc.Req.Header.Set(mergeMatchersHeader, tc.headerValue) matchers, err := parseMergeMatchersHeader(rc) if tc.expectedError { require.Error(t, err) } else { require.NoError(t, err) require.ElementsMatch(t, tc.expectedMatchers, matchers) } }) } } func TestParseConfigIdentifierHeader(t *testing.T) { testCases := []struct { name string headerValue string expectedValue string expectedError bool }{ { name: "valid identifier should parse correctly", headerValue: "test-config", expectedValue: "test-config", expectedError: false, }, { name: "identifier with spaces should be trimmed", headerValue: " test-config ", expectedValue: "test-config", expectedError: false, }, { name: "empty identifier should return the default value", headerValue: "", expectedValue: defaultConfigIdentifier, }, { name: "whitespace only identifier should return the default value", headerValue: " ", expectedValue: defaultConfigIdentifier, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { rc := createRequestCtx() rc.Req.Header.Set(configIdentifierHeader, tc.headerValue) identifier := parseConfigIdentifierHeader(rc) require.Equal(t, tc.expectedValue, identifier) }) } } func TestFormatMergeMatchers(t *testing.T) { t.Run("empty matchers should return empty string", func(t *testing.T) { result := formatMergeMatchers(nil) require.Equal(t, "", result) }) t.Run("single matcher should format correctly", func(t *testing.T) { matchers := amconfig.Matchers{ &labels.Matcher{ Type: labels.MatchEqual, Name: "env", Value: "prod", }, } result := formatMergeMatchers(matchers) require.Equal(t, "env=prod", result) }) t.Run("multiple matchers should format correctly", func(t *testing.T) { matchers := amconfig.Matchers{ &labels.Matcher{ Type: labels.MatchEqual, Name: "env", Value: "prod", }, &labels.Matcher{ Type: labels.MatchEqual, Name: "team", Value: "backend", }, } result := formatMergeMatchers(matchers) require.Equal(t, "env=prod,team=backend", result) }) } func TestRouteConvertPrometheusDeleteAlertmanagerConfig(t *testing.T) { const identifier = "test-config" const orgID = int64(1) mockAM := &mockAlertmanager{} ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI) srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft)) t.Run("should parse identifier header and call DeleteExtraConfiguration", func(t *testing.T) { mockAM.On("DeleteExtraConfiguration", mock.Anything, orgID, identifier).Return(nil).Once() rc := createRequestCtx() rc.Req.Header.Set(configIdentifierHeader, identifier) response := srv.RouteConvertPrometheusDeleteAlertmanagerConfig(rc) require.Equal(t, http.StatusAccepted, response.Status()) mockAM.AssertExpectations(t) }) t.Run("should use default identifier when header is missing", func(t *testing.T) { mockAM.On("DeleteExtraConfiguration", mock.Anything, orgID, defaultConfigIdentifier).Return(nil).Once() rc := createRequestCtx() response := srv.RouteConvertPrometheusDeleteAlertmanagerConfig(rc) require.Equal(t, http.StatusAccepted, response.Status()) mockAM.AssertExpectations(t) }) t.Run("should return error when DeleteExtraConfiguration fails", func(t *testing.T) { mockAM.On("DeleteExtraConfiguration", mock.Anything, orgID, identifier).Return(errors.New("delete error")).Once() rc := createRequestCtx() rc.Req.Header.Set(configIdentifierHeader, identifier) response := srv.RouteConvertPrometheusDeleteAlertmanagerConfig(rc) require.Equal(t, http.StatusInternalServerError, response.Status()) mockAM.AssertExpectations(t) }) t.Run("should return not implemented when feature toggle is disabled", func(t *testing.T) { ft := featuremgmt.WithFeatures() srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft)) rc := createRequestCtx() rc.Req.Header.Set(configIdentifierHeader, identifier) response := srv.RouteConvertPrometheusDeleteAlertmanagerConfig(rc) require.Equal(t, http.StatusNotImplemented, response.Status()) }) t.Run("should use default identifier for empty identifier header", func(t *testing.T) { mockAM.On("DeleteExtraConfiguration", mock.Anything, orgID, defaultConfigIdentifier).Return(nil).Once() rc := createRequestCtx() rc.Req.Header.Set(configIdentifierHeader, "") response := srv.RouteConvertPrometheusDeleteAlertmanagerConfig(rc) require.Equal(t, http.StatusAccepted, response.Status()) mockAM.AssertExpectations(t) }) }