package alerting import ( "bytes" "encoding/json" "fmt" "io" "net/http" "regexp" "testing" "time" "github.com/go-openapi/strfmt" amv2 "github.com/prometheus/alertmanager/api/v2/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/services/featuremgmt" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/util" ) func TestIntegrationAMConfigAccess(t *testing.T) { testinfra.SQLiteIntegrationTest(t) dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, EnableUnifiedAlerting: true, DisableAnonymous: true, AppModeProduction: true, DisableFeatureToggles: []string{ featuremgmt.FlagAlertingApiServer, }, }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) // Create a users to make authenticated requests createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ DefaultOrgRole: string(org.RoleViewer), Password: "viewer", Login: "viewer", }) createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ DefaultOrgRole: string(org.RoleEditor), Password: "editor", Login: "editor", }) createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ DefaultOrgRole: string(org.RoleAdmin), Password: "admin", Login: "admin", }) type testCase struct { desc string url string expStatus int expBody string } t.Run("when creating alertmanager configuration", func(t *testing.T) { body := ` { "alertmanager_config": { "route": { "receiver": "grafana-default-email" }, "receivers": [{ "name": "grafana-default-email", "grafana_managed_receiver_configs": [{ "uid": "", "name": "email receiver", "type": "email", "isDefault": true, "settings": { "addresses": "" } }] }] } } ` testCases := []testCase{ { desc: "un-authenticated request should fail", url: "http://%s/api/alertmanager/grafana/config/api/v1/alerts", expStatus: http.StatusUnauthorized, expBody: `"message":"Unauthorized"`, }, { desc: "viewer request should fail", url: "http://viewer:viewer@%s/api/alertmanager/grafana/config/api/v1/alerts", expStatus: http.StatusForbidden, expBody: `"title":"Access denied"`, }, { desc: "editor request should succeed", url: "http://editor:editor@%s/api/alertmanager/grafana/config/api/v1/alerts", expStatus: http.StatusAccepted, expBody: `{"message":"configuration created"}`, }, { desc: "admin request should succeed", url: "http://admin:admin@%s/api/alertmanager/grafana/config/api/v1/alerts", expStatus: http.StatusAccepted, expBody: `{"message":"configuration created"}`, }, } for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { url := fmt.Sprintf(tc.url, grafanaListedAddr) buf := bytes.NewReader([]byte(body)) // nolint:gosec resp, err := http.Post(url, "application/json", buf) t.Cleanup(func() { require.NoError(t, resp.Body.Close()) }) require.NoError(t, err) require.Equal(t, tc.expStatus, resp.StatusCode) b, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Contains(t, string(b), tc.expBody) }) } }) t.Run("when retrieve alertmanager configuration", func(t *testing.T) { cfgTemplate := ` { "template_files": null, "alertmanager_config": { "route": %s, "receivers": [{ "name": "grafana-default-email", "grafana_managed_receiver_configs": [{ "disableResolveMessage": false, "uid": "", "name": "email receiver", "type": "email", "secureFields": {}, "settings": { "addresses": "" } }] }] } } ` cfgWithoutAutogen := fmt.Sprintf(cfgTemplate, `{ "receiver": "grafana-default-email" }`) cfgWithAutogen := fmt.Sprintf(cfgTemplate, `{ "receiver": "grafana-default-email", "routes": [{ "receiver": "grafana-default-email", "object_matchers": [["__grafana_autogenerated__", "=", "true"]], "routes": [{ "receiver": "grafana-default-email", "group_by": ["grafana_folder", "alertname"], "object_matchers": [["__grafana_receiver__", "=", "grafana-default-email"]] }] }] }`) testCases := []testCase{ { desc: "un-authenticated request should fail", url: "http://%s/api/alertmanager/grafana/config/api/v1/alerts", expStatus: http.StatusUnauthorized, expBody: `{"extra":null,"message":"Unauthorized","messageId":"auth.unauthorized","statusCode":401,"traceID":""}`, }, { desc: "viewer request should succeed", url: "http://viewer:viewer@%s/api/alertmanager/grafana/config/api/v1/alerts", expStatus: http.StatusOK, expBody: cfgWithoutAutogen, }, { desc: "editor request should succeed", url: "http://editor:editor@%s/api/alertmanager/grafana/config/api/v1/alerts", expStatus: http.StatusOK, expBody: cfgWithoutAutogen, }, { desc: "admin request should succeed", url: "http://admin:admin@%s/api/alertmanager/grafana/config/api/v1/alerts", expStatus: http.StatusOK, expBody: cfgWithAutogen, }, } for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { resp, err := http.Get(fmt.Sprintf(tc.url, grafanaListedAddr)) t.Cleanup(func() { require.NoError(t, resp.Body.Close()) }) require.NoError(t, err) require.Equal(t, tc.expStatus, resp.StatusCode) b, err := io.ReadAll(resp.Body) if tc.expStatus == http.StatusOK { re := regexp.MustCompile(`"uid":"([\w|-]+)"`) b = re.ReplaceAll(b, []byte(`"uid":""`)) } require.NoError(t, err) require.JSONEq(t, tc.expBody, string(b)) }) } }) t.Run("when creating silence", func(t *testing.T) { now := time.Now() body := fmt.Sprintf(` { "comment": "string", "createdBy": "string", "matchers": [ { "isRegex": true, "name": "string", "value": "string" } ], "startsAt": "%s", "endsAt": "%s" } `, now.Format(time.RFC3339), now.Add(10*time.Second).Format(time.RFC3339)) testCases := []testCase{ { desc: "un-authenticated request should fail", url: "http://%s/api/alertmanager/grafana/config/api/v2/silences", expStatus: http.StatusUnauthorized, expBody: `"message":"Unauthorized"`, }, { desc: "viewer request should fail", url: "http://viewer:viewer@%s/api/alertmanager/grafana/api/v2/silences", expStatus: http.StatusForbidden, expBody: `"title":"Access denied"`, }, { desc: "editor request should succeed", url: "http://editor:editor@%s/api/alertmanager/grafana/api/v2/silences", expStatus: http.StatusAccepted, }, { desc: "admin request should succeed", url: "http://admin:admin@%s/api/alertmanager/grafana/api/v2/silences", expStatus: http.StatusAccepted, }, } for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { url := fmt.Sprintf(tc.url, grafanaListedAddr) buf := bytes.NewReader([]byte(body)) // nolint:gosec resp, err := http.Post(url, "application/json", buf) t.Cleanup(func() { require.NoError(t, resp.Body.Close()) }) require.NoError(t, err) require.Equal(t, tc.expStatus, resp.StatusCode) b, err := io.ReadAll(resp.Body) require.NoError(t, err) if tc.expStatus == http.StatusAccepted { response := apimodels.PostSilencesOKBody{} require.NoError(t, json.Unmarshal(b, &response)) require.NotEmpty(t, response.SilenceID) return } require.Contains(t, string(b), tc.expBody) }) } }) var blob []byte t.Run("when getting silences", func(t *testing.T) { testCases := []testCase{ { desc: "un-authenticated request should fail", url: "http://%s/api/alertmanager/grafana/api/v2/silences", expStatus: http.StatusUnauthorized, expBody: `"message": "Unauthorized"`, }, { desc: "viewer request should succeed", url: "http://viewer:viewer@%s/api/alertmanager/grafana/api/v2/silences", expStatus: http.StatusOK, }, { desc: "editor request should succeed", url: "http://editor:editor@%s/api/alertmanager/grafana/api/v2/silences", expStatus: http.StatusOK, }, { desc: "admin request should succeed", url: "http://admin:admin@%s/api/alertmanager/grafana/api/v2/silences", expStatus: http.StatusOK, }, } for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { url := fmt.Sprintf(tc.url, grafanaListedAddr) // nolint:gosec resp, err := http.Get(url) t.Cleanup(func() { require.NoError(t, resp.Body.Close()) }) require.NoError(t, err) require.Equal(t, tc.expStatus, resp.StatusCode) require.NoError(t, err) if tc.expStatus == http.StatusOK { b, err := io.ReadAll(resp.Body) require.NoError(t, err) blob = b } }) } }) var silences apimodels.GettableSilences err := json.Unmarshal(blob, &silences) require.NoError(t, err) assert.Len(t, silences, 2) silenceIDs := make([]string, 0, len(silences)) for _, s := range silences { silenceIDs = append(silenceIDs, *s.ID) } unconsumedSilenceIdx := 0 t.Run("when deleting a silence", func(t *testing.T) { testCases := []testCase{ { desc: "un-authenticated request should fail", url: "http://%s/api/alertmanager/grafana/api/v2/silence/%s", expStatus: http.StatusUnauthorized, expBody: `"message":"Unauthorized"`, }, { desc: "viewer request should fail", url: "http://viewer:viewer@%s/api/alertmanager/grafana/api/v2/silence/%s", expStatus: http.StatusForbidden, expBody: `"title":"Access denied"`, }, { desc: "editor request should succeed", url: "http://editor:editor@%s/api/alertmanager/grafana/api/v2/silence/%s", expStatus: http.StatusOK, expBody: `{"message":"silence deleted"}`, }, { desc: "admin request should succeed", url: "http://admin:admin@%s/api/alertmanager/grafana/api/v2/silence/%s", expStatus: http.StatusOK, expBody: `{"message":"silence deleted"}`, }, } for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { url := fmt.Sprintf(tc.url, grafanaListedAddr, silenceIDs[unconsumedSilenceIdx]) // Create client client := &http.Client{} // Create request req, err := http.NewRequest("DELETE", url, nil) if err != nil { fmt.Println(err) return } // Fetch Request resp, err := client.Do(req) if err != nil { return } t.Cleanup(func() { require.NoError(t, resp.Body.Close()) }) require.NoError(t, err) require.Equal(t, tc.expStatus, resp.StatusCode) b, err := io.ReadAll(resp.Body) require.NoError(t, err) if tc.expStatus == http.StatusOK { unconsumedSilenceIdx++ } require.Contains(t, string(b), tc.expBody) }) } }) } func TestIntegrationAlertmanagerCreateSilence(t *testing.T) { testinfra.SQLiteIntegrationTest(t) dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, EnableUnifiedAlerting: true, AppModeProduction: true, }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ DefaultOrgRole: string(org.RoleAdmin), Password: "admin", Login: "admin", }) client := newAlertingApiClient(grafanaListedAddr, "admin", "admin") cases := []struct { name string silence apimodels.PostableSilence expErr string }{{ name: "can create silence for foo=bar", silence: apimodels.PostableSilence{ Silence: amv2.Silence{ Comment: util.Pointer("This is a comment"), CreatedBy: util.Pointer("test"), EndsAt: util.Pointer(strfmt.DateTime(time.Now().Add(time.Minute))), Matchers: amv2.Matchers{{ IsEqual: util.Pointer(true), IsRegex: util.Pointer(false), Name: util.Pointer("foo"), Value: util.Pointer("bar"), }}, StartsAt: util.Pointer(strfmt.DateTime(time.Now())), }, }, }, { name: "can create silence for _foo1=bar", silence: apimodels.PostableSilence{ Silence: amv2.Silence{ Comment: util.Pointer("This is a comment"), CreatedBy: util.Pointer("test"), EndsAt: util.Pointer(strfmt.DateTime(time.Now().Add(time.Minute))), Matchers: amv2.Matchers{{ IsEqual: util.Pointer(true), IsRegex: util.Pointer(false), Name: util.Pointer("_foo1"), Value: util.Pointer("bar"), }}, StartsAt: util.Pointer(strfmt.DateTime(time.Now())), }, }, }, { name: "can create silence for 0foo=bar", silence: apimodels.PostableSilence{ Silence: amv2.Silence{ Comment: util.Pointer("This is a comment"), CreatedBy: util.Pointer("test"), EndsAt: util.Pointer(strfmt.DateTime(time.Now().Add(time.Minute))), Matchers: amv2.Matchers{{ IsEqual: util.Pointer(true), IsRegex: util.Pointer(false), Name: util.Pointer("0foo"), Value: util.Pointer("bar"), }}, StartsAt: util.Pointer(strfmt.DateTime(time.Now())), }, }, }, { name: "can create silence for foo=🙂bar", silence: apimodels.PostableSilence{ Silence: amv2.Silence{ Comment: util.Pointer("This is a comment"), CreatedBy: util.Pointer("test"), EndsAt: util.Pointer(strfmt.DateTime(time.Now().Add(time.Minute))), Matchers: amv2.Matchers{{ IsEqual: util.Pointer(true), IsRegex: util.Pointer(false), Name: util.Pointer("foo"), Value: util.Pointer("🙂bar"), }}, StartsAt: util.Pointer(strfmt.DateTime(time.Now())), }, }, }, { name: "can create silence for foo🙂=bar", silence: apimodels.PostableSilence{ Silence: amv2.Silence{ Comment: util.Pointer("This is a comment"), CreatedBy: util.Pointer("test"), EndsAt: util.Pointer(strfmt.DateTime(time.Now().Add(time.Minute))), Matchers: amv2.Matchers{{ IsEqual: util.Pointer(true), IsRegex: util.Pointer(false), Name: util.Pointer("foo🙂"), Value: util.Pointer("bar"), }}, StartsAt: util.Pointer(strfmt.DateTime(time.Now())), }, }, }, { name: "can't create silence for missing label name", silence: apimodels.PostableSilence{ Silence: amv2.Silence{ Comment: util.Pointer("This is a comment"), CreatedBy: util.Pointer("test"), EndsAt: util.Pointer(strfmt.DateTime(time.Now().Add(time.Minute))), Matchers: amv2.Matchers{{ IsEqual: util.Pointer(true), IsRegex: util.Pointer(false), Name: util.Pointer(""), Value: util.Pointer("bar"), }}, StartsAt: util.Pointer(strfmt.DateTime(time.Now())), }, }, expErr: "unable to upsert silence: invalid silence: invalid label matcher 0: invalid label name \"\": unable to create silence", }, { name: "can't create silence for missing label value", silence: apimodels.PostableSilence{ Silence: amv2.Silence{ Comment: util.Pointer("This is a comment"), CreatedBy: util.Pointer("test"), EndsAt: util.Pointer(strfmt.DateTime(time.Now().Add(time.Minute))), Matchers: amv2.Matchers{{ IsEqual: util.Pointer(true), IsRegex: util.Pointer(false), Name: util.Pointer("foo"), Value: util.Pointer(""), }}, StartsAt: util.Pointer(strfmt.DateTime(time.Now())), }, }, expErr: "unable to upsert silence: invalid silence: at least one matcher must not match the empty string: unable to create silence", }} for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { silenceOkBody, status, body := client.PostSilence(t, tc.silence) t.Log(body) if tc.expErr != "" { assert.NotEqual(t, http.StatusAccepted, status) var validationError errutil.PublicError assert.NoError(t, json.Unmarshal([]byte(body), &validationError)) assert.Contains(t, validationError.Message, tc.expErr) assert.Empty(t, silenceOkBody.SilenceID) } else { assert.Equal(t, http.StatusAccepted, status) assert.NotEmpty(t, silenceOkBody.SilenceID) } }) } } func TestIntegrationAlertmanagerStatus(t *testing.T) { testinfra.SQLiteIntegrationTest(t) // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, EnableUnifiedAlerting: true, DisableAnonymous: true, AppModeProduction: true, }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) // Create a users to make authenticated requests createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ DefaultOrgRole: string(org.RoleViewer), Password: "viewer", Login: "viewer", }) createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ DefaultOrgRole: string(org.RoleEditor), Password: "editor", Login: "editor", }) createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ DefaultOrgRole: string(org.RoleAdmin), Password: "admin", Login: "admin", }) type testCase struct { desc string url string expStatus int expBody string } cfgTemplate := ` { "cluster": { "peers": [], "status": "disabled" }, "config": { "route": %s, "receivers": [{ "name": "grafana-default-email", "grafana_managed_receiver_configs": [{ "uid": "", "name": "email receiver", "type": "email", "disableResolveMessage": false, "settings": { "addresses": "\u003cexample@email.com\u003e" } }] }] }, "uptime": null, "versionInfo": { "branch": "N/A", "buildDate": "N/A", "buildUser": "N/A", "goVersion": "N/A", "revision": "N/A", "version": "N/A" } } ` cfgWithoutAutogen := fmt.Sprintf(cfgTemplate, `{ "receiver": "grafana-default-email", "group_by": ["grafana_folder", "alertname"] }`) cfgWithAutogen := fmt.Sprintf(cfgTemplate, `{ "receiver": "grafana-default-email", "routes": [{ "receiver": "grafana-default-email", "object_matchers": [["__grafana_autogenerated__", "=", "true"]], "routes": [{ "receiver": "grafana-default-email", "group_by": ["grafana_folder", "alertname"], "object_matchers": [["__grafana_receiver__", "=", "grafana-default-email"]] }] }], "group_by": ["grafana_folder", "alertname"] }`) testCases := []testCase{ { desc: "un-authenticated request should fail", url: "http://%s/api/alertmanager/grafana/api/v2/status", expStatus: http.StatusUnauthorized, expBody: `{"extra":null,"message":"Unauthorized","messageId":"auth.unauthorized","statusCode":401,"traceID":""}`, }, { desc: "viewer request should succeed", url: "http://viewer:viewer@%s/api/alertmanager/grafana/api/v2/status", expStatus: http.StatusOK, expBody: cfgWithoutAutogen, }, { desc: "editor request should succeed", url: "http://editor:editor@%s/api/alertmanager/grafana/api/v2/status", expStatus: http.StatusOK, expBody: cfgWithoutAutogen, }, { desc: "admin request should succeed", url: "http://admin:admin@%s/api/alertmanager/grafana/api/v2/status", expStatus: http.StatusOK, expBody: cfgWithAutogen, }, } for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { resp, err := http.Get(fmt.Sprintf(tc.url, grafanaListedAddr)) t.Cleanup(func() { require.NoError(t, resp.Body.Close()) }) require.NoError(t, err) require.Equal(t, tc.expStatus, resp.StatusCode) b, err := io.ReadAll(resp.Body) if tc.expStatus == http.StatusOK { re := regexp.MustCompile(`"uid":"([\w|-]+)"`) b = re.ReplaceAll(b, []byte(`"uid":""`)) } require.NoError(t, err) require.JSONEq(t, tc.expBody, string(b)) }) } }