package api import ( "context" "encoding/json" "math/rand" "net/http" "testing" "time" "github.com/go-openapi/strfmt" amv2 "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" acMock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/metrics" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "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" ) func TestContextWithTimeoutFromRequest(t *testing.T) { t.Run("assert context has default timeout when header is absent", func(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil) require.NoError(t, err) now := time.Now() ctx := context.Background() ctx, cancelFunc, err := contextWithTimeoutFromRequest( ctx, req, 15*time.Second, 30*time.Second) require.NoError(t, err) require.NotNil(t, cancelFunc) require.NotNil(t, ctx) deadline, ok := ctx.Deadline() require.True(t, ok) require.True(t, deadline.After(now)) require.Less(t, deadline.Sub(now).Seconds(), 30.0) require.GreaterOrEqual(t, deadline.Sub(now).Seconds(), 15.0) }) t.Run("assert context has timeout in request header", func(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil) require.NoError(t, err) req.Header.Set("Request-Timeout", "5") now := time.Now() ctx := context.Background() ctx, cancelFunc, err := contextWithTimeoutFromRequest( ctx, req, 15*time.Second, 30*time.Second) require.NoError(t, err) require.NotNil(t, cancelFunc) require.NotNil(t, ctx) deadline, ok := ctx.Deadline() require.True(t, ok) require.True(t, deadline.After(now)) require.Less(t, deadline.Sub(now).Seconds(), 15.0) require.GreaterOrEqual(t, deadline.Sub(now).Seconds(), 5.0) }) t.Run("assert timeout in request header cannot exceed max timeout", func(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil) require.NoError(t, err) req.Header.Set("Request-Timeout", "60") ctx := context.Background() ctx, cancelFunc, err := contextWithTimeoutFromRequest( ctx, req, 15*time.Second, 30*time.Second) require.Error(t, err, "exceeded maximum timeout") require.Nil(t, cancelFunc) require.Nil(t, ctx) }) } func TestStatusForTestReceivers(t *testing.T) { t.Run("assert HTTP 400 Status Bad Request for no receivers", func(t *testing.T) { require.Equal(t, http.StatusBadRequest, statusForTestReceivers([]notifier.TestReceiverResult{})) }) t.Run("assert HTTP 400 Bad Request when all invalid receivers", func(t *testing.T) { require.Equal(t, http.StatusBadRequest, statusForTestReceivers([]notifier.TestReceiverResult{{ Name: "test1", Configs: []notifier.TestReceiverConfigResult{{ Name: "test1", UID: "uid1", Status: "failed", Error: notifier.InvalidReceiverError{}, }}, }, { Name: "test2", Configs: []notifier.TestReceiverConfigResult{{ Name: "test2", UID: "uid2", Status: "failed", Error: notifier.InvalidReceiverError{}, }}, }})) }) t.Run("assert HTTP 408 Request Timeout when all receivers timed out", func(t *testing.T) { require.Equal(t, http.StatusRequestTimeout, statusForTestReceivers([]notifier.TestReceiverResult{{ Name: "test1", Configs: []notifier.TestReceiverConfigResult{{ Name: "test1", UID: "uid1", Status: "failed", Error: notifier.ReceiverTimeoutError{}, }}, }, { Name: "test2", Configs: []notifier.TestReceiverConfigResult{{ Name: "test2", UID: "uid2", Status: "failed", Error: notifier.ReceiverTimeoutError{}, }}, }})) }) t.Run("assert 207 Multi Status for different errors", func(t *testing.T) { require.Equal(t, http.StatusMultiStatus, statusForTestReceivers([]notifier.TestReceiverResult{{ Name: "test1", Configs: []notifier.TestReceiverConfigResult{{ Name: "test1", UID: "uid1", Status: "failed", Error: notifier.InvalidReceiverError{}, }}, }, { Name: "test2", Configs: []notifier.TestReceiverConfigResult{{ Name: "test2", UID: "uid2", Status: "failed", Error: notifier.ReceiverTimeoutError{}, }}, }})) }) } func TestAlertmanagerConfig(t *testing.T) { sut := createSut(t, nil) t.Run("assert 404 Not Found when applying config to nonexistent org", func(t *testing.T) { rc := models.ReqContext{ Context: &web.Context{ Req: &http.Request{}, }, SignedInUser: &user.SignedInUser{ OrgID: 12, }, } request := createAmConfigRequest(t) response := sut.RoutePostAlertingConfig(&rc, request) require.Equal(t, 404, response.Status()) require.Contains(t, string(response.Body()), "Alertmanager does not exist for this organization") }) t.Run("assert 202 when config successfully applied", func(t *testing.T) { rc := models.ReqContext{ Context: &web.Context{ Req: &http.Request{}, }, SignedInUser: &user.SignedInUser{ OrgID: 1, }, } request := createAmConfigRequest(t) response := sut.RoutePostAlertingConfig(&rc, request) require.Equal(t, 202, response.Status()) }) t.Run("assert 202 when alertmanager to configure is not ready", func(t *testing.T) { sut := createSut(t, nil) rc := models.ReqContext{ Context: &web.Context{ Req: &http.Request{}, }, SignedInUser: &user.SignedInUser{ OrgID: 3, // Org 3 was initialized with broken config. }, } request := createAmConfigRequest(t) response := sut.RoutePostAlertingConfig(&rc, request) require.Equal(t, 202, response.Status()) }) t.Run("when objects are not provisioned", func(t *testing.T) { t.Run("route from GET config has no provenance", func(t *testing.T) { sut := createSut(t, nil) rc := createRequestCtxInOrg(1) response := sut.RouteGetAlertingConfig(rc) body := asGettableUserConfig(t, response) require.Equal(t, ngmodels.ProvenanceNone, body.AlertmanagerConfig.Route.Provenance) }) t.Run("contact point from GET config has no provenance", func(t *testing.T) { sut := createSut(t, nil) rc := createRequestCtxInOrg(1) response := sut.RouteGetAlertingConfig(rc) body := asGettableUserConfig(t, response) require.Equal(t, ngmodels.ProvenanceNone, body.AlertmanagerConfig.Receivers[0].GrafanaManagedReceivers[0].Provenance) }) t.Run("templates from GET config have no provenance", func(t *testing.T) { sut := createSut(t, nil) rc := createRequestCtxInOrg(1) response := sut.RouteGetAlertingConfig(rc) body := asGettableUserConfig(t, response) require.Nil(t, body.TemplateFileProvenances) }) }) t.Run("when objects are provisioned", func(t *testing.T) { t.Run("route from GET config has expected provenance", func(t *testing.T) { sut := createSut(t, nil) rc := createRequestCtxInOrg(1) setRouteProvenance(t, 1, sut.mam.ProvStore) response := sut.RouteGetAlertingConfig(rc) body := asGettableUserConfig(t, response) require.Equal(t, ngmodels.ProvenanceAPI, body.AlertmanagerConfig.Route.Provenance) }) t.Run("contact point from GET config has expected provenance", func(t *testing.T) { sut := createSut(t, nil) rc := createRequestCtxInOrg(1) request := createAmConfigRequest(t) _ = sut.RoutePostAlertingConfig(rc, request) response := sut.RouteGetAlertingConfig(rc) body := asGettableUserConfig(t, response) cpUID := body.AlertmanagerConfig.Receivers[0].GrafanaManagedReceivers[0].UID require.NotEmpty(t, cpUID) setContactPointProvenance(t, 1, cpUID, sut.mam.ProvStore) response = sut.RouteGetAlertingConfig(rc) body = asGettableUserConfig(t, response) require.Equal(t, ngmodels.ProvenanceAPI, body.AlertmanagerConfig.Receivers[0].GrafanaManagedReceivers[0].Provenance) }) t.Run("templates from GET config have expected provenance", func(t *testing.T) { sut := createSut(t, nil) rc := createRequestCtxInOrg(1) setTemplateProvenance(t, 1, "a", sut.mam.ProvStore) response := sut.RouteGetAlertingConfig(rc) body := asGettableUserConfig(t, response) require.NotNil(t, body.TemplateFileProvenances) require.Len(t, body.TemplateFileProvenances, 1) require.Equal(t, ngmodels.ProvenanceAPI, body.TemplateFileProvenances["a"]) }) }) } func TestSilenceCreate(t *testing.T) { makeSilence := func(comment string, createdBy string, startsAt, endsAt strfmt.DateTime, matchers amv2.Matchers) amv2.Silence { return amv2.Silence{ Comment: &comment, CreatedBy: &createdBy, StartsAt: &startsAt, EndsAt: &endsAt, Matchers: matchers, } } now := time.Now() dt := func(t time.Time) strfmt.DateTime { return strfmt.DateTime(t) } tru := true testString := "testName" matchers := amv2.Matchers{&amv2.Matcher{Name: &testString, IsEqual: &tru, IsRegex: &tru, Value: &testString}} cases := []struct { name string silence amv2.Silence status int }{ {"Valid Silence", makeSilence("", "tests", dt(now), dt(now.Add(1*time.Second)), matchers), http.StatusAccepted, }, {"No Comment Silence", func() amv2.Silence { s := makeSilence("", "tests", dt(now), dt(now.Add(1*time.Second)), matchers) s.Comment = nil return s }(), http.StatusBadRequest, }, } for _, cas := range cases { t.Run(cas.name, func(t *testing.T) { rc := models.ReqContext{ Context: &web.Context{ Req: &http.Request{}, }, SignedInUser: &user.SignedInUser{ OrgRole: org.RoleEditor, OrgID: 1, }, } srv := createSut(t, nil) resp := srv.RouteCreateSilence(&rc, amv2.PostableSilence{ ID: "", Silence: cas.silence, }) require.Equal(t, cas.status, resp.Status()) }) } } func TestRouteCreateSilence(t *testing.T) { tesCases := []struct { name string silence func() apimodels.PostableSilence accessControl func() accesscontrol.AccessControl role org.RoleType expectedStatus int }{ { name: "new silence, role-based access control is enabled, not authorized", silence: silenceGen(withEmptyID), accessControl: func() accesscontrol.AccessControl { return acMock.New() }, expectedStatus: http.StatusUnauthorized, }, { name: "new silence, role-based access control is enabled, authorized", silence: silenceGen(withEmptyID), accessControl: func() accesscontrol.AccessControl { return acMock.New().WithPermissions([]accesscontrol.Permission{ {Action: accesscontrol.ActionAlertingInstanceCreate}, }) }, expectedStatus: http.StatusAccepted, }, { name: "new silence, role-based access control is disabled, Viewer", silence: silenceGen(withEmptyID), accessControl: func() accesscontrol.AccessControl { return acMock.New().WithDisabled() }, role: org.RoleViewer, expectedStatus: http.StatusUnauthorized, }, { name: "new silence, role-based access control is disabled, Editor", silence: silenceGen(withEmptyID), accessControl: func() accesscontrol.AccessControl { return acMock.New().WithDisabled() }, role: org.RoleEditor, expectedStatus: http.StatusAccepted, }, { name: "new silence, role-based access control is disabled, Admin", silence: silenceGen(withEmptyID), accessControl: func() accesscontrol.AccessControl { return acMock.New().WithDisabled() }, role: org.RoleAdmin, expectedStatus: http.StatusAccepted, }, { name: "update silence, role-based access control is enabled, not authorized", silence: silenceGen(), accessControl: func() accesscontrol.AccessControl { return acMock.New() }, expectedStatus: http.StatusUnauthorized, }, { name: "update silence, role-based access control is enabled, authorized", silence: silenceGen(), accessControl: func() accesscontrol.AccessControl { return acMock.New().WithPermissions([]accesscontrol.Permission{ {Action: accesscontrol.ActionAlertingInstanceUpdate}, }) }, expectedStatus: http.StatusAccepted, }, { name: "update silence, role-based access control is disabled, Viewer", silence: silenceGen(), accessControl: func() accesscontrol.AccessControl { return acMock.New().WithDisabled() }, role: org.RoleViewer, expectedStatus: http.StatusUnauthorized, }, { name: "update silence, role-based access control is disabled, Editor", silence: silenceGen(), accessControl: func() accesscontrol.AccessControl { return acMock.New().WithDisabled() }, role: org.RoleEditor, expectedStatus: http.StatusAccepted, }, { name: "update silence, role-based access control is disabled, Admin", silence: silenceGen(), accessControl: func() accesscontrol.AccessControl { return acMock.New().WithDisabled() }, role: org.RoleAdmin, expectedStatus: http.StatusAccepted, }, } for _, tesCase := range tesCases { t.Run(tesCase.name, func(t *testing.T) { ac := tesCase.accessControl() sut := createSut(t, ac) rc := models.ReqContext{ Context: &web.Context{ Req: &http.Request{}, }, SignedInUser: &user.SignedInUser{ OrgRole: tesCase.role, OrgID: 1, }, } silence := tesCase.silence() if silence.ID != "" { alertmanagerFor, err := sut.mam.AlertmanagerFor(1) require.NoError(t, err) silence.ID = "" newID, err := alertmanagerFor.CreateSilence(&silence) require.NoError(t, err) silence.ID = newID } response := sut.RouteCreateSilence(&rc, silence) require.Equal(t, tesCase.expectedStatus, response.Status()) }) } } func createSut(t *testing.T, accessControl accesscontrol.AccessControl) AlertmanagerSrv { t.Helper() mam := createMultiOrgAlertmanager(t) if accessControl == nil { accessControl = acMock.New().WithDisabled() } log := log.NewNopLogger() return AlertmanagerSrv{ mam: mam, crypto: mam.Crypto, ac: accessControl, log: log, } } func createAmConfigRequest(t *testing.T) apimodels.PostableUserConfig { t.Helper() request := apimodels.PostableUserConfig{} err := request.UnmarshalJSON([]byte(validConfig)) require.NoError(t, err) return request } func createMultiOrgAlertmanager(t *testing.T) *notifier.MultiOrgAlertmanager { t.Helper() configs := map[int64]*ngmodels.AlertConfiguration{ 1: {AlertmanagerConfiguration: validConfig, OrgID: 1}, 2: {AlertmanagerConfiguration: validConfig, OrgID: 2}, 3: {AlertmanagerConfiguration: brokenConfig, OrgID: 3}, } configStore := notifier.NewFakeConfigStore(t, configs) orgStore := notifier.NewFakeOrgStore(t, []int64{1, 2, 3}) provStore := provisioning.NewFakeProvisioningStore() tmpDir := t.TempDir() kvStore := notifier.NewFakeKVStore(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) reg := prometheus.NewPedanticRegistry() m := metrics.NewNGAlert(reg) decryptFn := secretsService.GetDecryptedValue cfg := &setting.Cfg{ DataPath: tmpDir, UnifiedAlerting: setting.UnifiedAlertingSettings{ AlertmanagerConfigPollInterval: 3 * time.Minute, DefaultConfiguration: setting.GetAlertmanagerDefaultConfiguration(), DisabledOrgs: map[int64]struct{}{5: {}}, }, // do not poll in tests. } mam, err := notifier.NewMultiOrgAlertmanager(cfg, &configStore, &orgStore, kvStore, provStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService) require.NoError(t, err) err = mam.LoadAndSyncAlertmanagersForOrgs(context.Background()) require.NoError(t, err) return mam } var validConfig = `{ "template_files": { "a": "template" }, "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": "" } }] }] } } ` var brokenConfig = ` "alertmanager_config": { "route": { "receiver": "grafana-default-email" }, "receivers": [{ "name": "grafana-default-email", "grafana_managed_receiver_configs": [{ "uid": "abc", "name": "default-email", "type": "email", "isDefault": true, "settings": {} }] }] } }` func silenceGen(mutatorFuncs ...func(*apimodels.PostableSilence)) func() apimodels.PostableSilence { return func() apimodels.PostableSilence { testString := util.GenerateShortUID() isEqual := rand.Int()%2 == 0 isRegex := rand.Int()%2 == 0 value := util.GenerateShortUID() if isRegex { value = ".*" + util.GenerateShortUID() } matchers := amv2.Matchers{&amv2.Matcher{Name: &testString, IsEqual: &isEqual, IsRegex: &isRegex, Value: &value}} comment := util.GenerateShortUID() starts := strfmt.DateTime(timeNow().Add(-time.Duration(rand.Int63n(9)+1) * time.Second)) ends := strfmt.DateTime(timeNow().Add(time.Duration(rand.Int63n(9)+1) * time.Second)) createdBy := "User-" + util.GenerateShortUID() s := apimodels.PostableSilence{ ID: util.GenerateShortUID(), Silence: amv2.Silence{ Comment: &comment, CreatedBy: &createdBy, EndsAt: &ends, Matchers: matchers, StartsAt: &starts, }, } for _, mutator := range mutatorFuncs { mutator(&s) } return s } } func withEmptyID(silence *apimodels.PostableSilence) { silence.ID = "" } func createRequestCtxInOrg(org int64) *models.ReqContext { return &models.ReqContext{ Context: &web.Context{ Req: &http.Request{}, }, SignedInUser: &user.SignedInUser{ OrgID: org, }, } } // setRouteProvenance marks an org's routing tree as provisioned. func setRouteProvenance(t *testing.T, orgID int64, ps provisioning.ProvisioningStore) { t.Helper() err := ps.SetProvenance(context.Background(), &apimodels.Route{}, orgID, ngmodels.ProvenanceAPI) require.NoError(t, err) } // setContactPointProvenance marks a contact point as provisioned. func setContactPointProvenance(t *testing.T, orgID int64, UID string, ps provisioning.ProvisioningStore) { t.Helper() err := ps.SetProvenance(context.Background(), &apimodels.EmbeddedContactPoint{UID: UID}, orgID, ngmodels.ProvenanceAPI) require.NoError(t, err) } // setTemplateProvenance marks a template as provisioned. func setTemplateProvenance(t *testing.T, orgID int64, name string, ps provisioning.ProvisioningStore) { t.Helper() err := ps.SetProvenance(context.Background(), &apimodels.MessageTemplate{Name: name}, orgID, ngmodels.ProvenanceAPI) require.NoError(t, err) } func asGettableUserConfig(t *testing.T, r response.Response) *apimodels.GettableUserConfig { t.Helper() body := &apimodels.GettableUserConfig{} err := json.Unmarshal(r.Body(), body) require.NoError(t, err) return body }