package migration import ( "context" "encoding/base64" "encoding/json" "fmt" "testing" "time" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" legacymodels "github.com/grafana/grafana/pkg/services/alerting/models" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" migmodels "github.com/grafana/grafana/pkg/services/ngalert/migration/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) func TestCreateRoute(t *testing.T) { tc := []struct { name string channel *legacymodels.AlertNotification recv *apimodels.PostableGrafanaReceiver expected *apimodels.Route }{ { name: "when a receiver is passed in, the route should exact match based on channel uid with continue=true", channel: &legacymodels.AlertNotification{UID: "uid1", Name: "recv1"}, recv: createPostableGrafanaReceiver("uid1", "recv1"), expected: &apimodels.Route{ Receiver: "recv1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("recv1"), Value: "true"}}, Routes: nil, Continue: true, GroupByStr: nil, RepeatInterval: durationPointer(DisabledRepeatInterval), }, }, { name: "notification channel labels matcher should work with special characters", channel: &legacymodels.AlertNotification{UID: "uid1", Name: `. ^ $ * + - ? ( ) [ ] { } \ |`}, recv: createPostableGrafanaReceiver("uid1", `. ^ $ * + - ? ( ) [ ] { } \ |`), expected: &apimodels.Route{ Receiver: `. ^ $ * + - ? ( ) [ ] { } \ |`, ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel(`. ^ $ * + - ? ( ) [ ] { } \ |`), Value: "true"}}, Routes: nil, Continue: true, GroupByStr: nil, RepeatInterval: durationPointer(DisabledRepeatInterval), }, }, { name: "when a channel has sendReminder=true, the route should use the frequency in repeat interval", channel: &legacymodels.AlertNotification{SendReminder: true, Frequency: time.Duration(42) * time.Hour, UID: "uid1", Name: "recv1"}, recv: createPostableGrafanaReceiver("uid1", "recv1"), expected: &apimodels.Route{ Receiver: "recv1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("recv1"), Value: "true"}}, Routes: nil, Continue: true, GroupByStr: nil, RepeatInterval: durationPointer(model.Duration(time.Duration(42) * time.Hour)), }, }, { name: "when a channel has sendReminder=false, the route should ignore the frequency in repeat interval and use DisabledRepeatInterval", channel: &legacymodels.AlertNotification{SendReminder: false, Frequency: time.Duration(42) * time.Hour, UID: "uid1", Name: "recv1"}, recv: createPostableGrafanaReceiver("uid1", "recv1"), expected: &apimodels.Route{ Receiver: "recv1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("recv1"), Value: "true"}}, Routes: nil, Continue: true, GroupByStr: nil, RepeatInterval: durationPointer(DisabledRepeatInterval), }, }, } for _, tt := range tc { t.Run(tt.name, func(t *testing.T) { res, err := createRoute(tt.channel, tt.recv.Name) require.NoError(t, err) // Order of nested routes is not guaranteed. cOpt := []cmp.Option{ cmpopts.SortSlices(func(a, b *apimodels.Route) bool { if a.Receiver != b.Receiver { return a.Receiver < b.Receiver } return a.ObjectMatchers[0].Value < b.ObjectMatchers[0].Value }), cmpopts.IgnoreUnexported(apimodels.Route{}, labels.Matcher{}), } if !cmp.Equal(tt.expected, res, cOpt...) { t.Errorf("Unexpected Route: %v", cmp.Diff(tt.expected, res, cOpt...)) } }) } } func createNotChannel(t *testing.T, uid string, id int64, name string, isDefault bool, frequency time.Duration) *legacymodels.AlertNotification { t.Helper() return &legacymodels.AlertNotification{ OrgID: 1, UID: uid, ID: id, Name: name, Type: "email", SendReminder: frequency > 0, Frequency: frequency, Settings: simplejson.New(), IsDefault: isDefault, Created: now, Updated: now, } } func createBasicNotChannel(t *testing.T, notType string) *legacymodels.AlertNotification { t.Helper() a := createNotChannel(t, "uid1", int64(1), "name1", false, 0) a.Type = notType return a } func TestCreateReceivers(t *testing.T) { tc := []struct { name string channel *legacymodels.AlertNotification expRecv *apimodels.PostableGrafanaReceiver expErr error }{ { name: "when given notification channels migrate them to receivers", channel: createNotChannel(t, "uid1", int64(1), "name1", false, 0), expRecv: createPostableGrafanaReceiver("uid1", "name1"), }, { name: "when given hipchat return discontinued error", channel: createBasicNotChannel(t, "hipchat"), expErr: fmt.Errorf("'hipchat': %w", ErrDiscontinued), }, { name: "when given sensu return discontinued error", channel: createBasicNotChannel(t, "sensu"), expErr: fmt.Errorf("'sensu': %w", ErrDiscontinued), }, } sqlStore := db.InitTestDB(t) for _, tt := range tc { t.Run(tt.name, func(t *testing.T) { service := NewTestMigrationService(t, sqlStore, nil) m := service.newOrgMigration(1) recv, err := m.createReceiver(tt.channel) if tt.expErr != nil { require.Error(t, err) require.EqualError(t, err, tt.expErr.Error()) return } require.NoError(t, err) require.Equal(t, tt.expRecv, recv) }) } } func TestMigrateNotificationChannelSecureSettings(t *testing.T) { cfg := setting.NewCfg() legacyEncryptFn := func(data string) string { raw, err := util.Encrypt([]byte(data), cfg.SecretKey) require.NoError(t, err) return string(raw) } decryptFn := func(data string, m *migrationService) string { decoded, err := base64.StdEncoding.DecodeString(data) require.NoError(t, err) raw, err := m.encryptionService.Decrypt(context.Background(), decoded) require.NoError(t, err) return string(raw) } gen := func(nType string, fn func(channel *legacymodels.AlertNotification)) *legacymodels.AlertNotification { not := &legacymodels.AlertNotification{ UID: "uid", ID: 1, Name: "channel name", Type: nType, Settings: simplejson.NewFromAny(map[string]any{ "something": "some value", }), SecureSettings: map[string][]byte{}, } if fn != nil { fn(not) } return not } genExpSlack := func(fn func(channel *apimodels.PostableGrafanaReceiver)) *apimodels.PostableGrafanaReceiver { rawSettings, err := json.Marshal(map[string]string{ "something": "some value", }) require.NoError(t, err) recv := &apimodels.PostableGrafanaReceiver{ UID: "uid", Name: "channel name", Type: "slack", Settings: rawSettings, SecureSettings: map[string]string{ "token": "secure token", "url": "secure url", }, } if fn != nil { fn(recv) } return recv } tc := []struct { name string channel *legacymodels.AlertNotification expRecv *apimodels.PostableGrafanaReceiver expErr error }{ { name: "when secure settings exist, migrate them to receiver secure settings", channel: gen("slack", func(channel *legacymodels.AlertNotification) { channel.SecureSettings = map[string][]byte{ "token": []byte(legacyEncryptFn("secure token")), "url": []byte(legacyEncryptFn("secure url")), } }), expRecv: genExpSlack(nil), }, { name: "when no secure settings are encrypted, do nothing", channel: gen("slack", nil), expRecv: genExpSlack(func(recv *apimodels.PostableGrafanaReceiver) { delete(recv.SecureSettings, "token") delete(recv.SecureSettings, "url") }), }, { name: "when some secure settings are available unencrypted in settings, migrate them to secureSettings and encrypt", channel: gen("slack", func(channel *legacymodels.AlertNotification) { channel.SecureSettings = map[string][]byte{ "url": []byte(legacyEncryptFn("secure url")), } channel.Settings.Set("token", "secure token") }), expRecv: genExpSlack(nil), }, } sqlStore := db.InitTestDB(t) for _, tt := range tc { t.Run(tt.name, func(t *testing.T) { service := NewTestMigrationService(t, sqlStore, nil) m := service.newOrgMigration(1) recv, err := m.createReceiver(tt.channel) if tt.expErr != nil { require.Error(t, err) require.EqualError(t, err, tt.expErr.Error()) return } require.NoError(t, err) if len(tt.expRecv.SecureSettings) > 0 { require.NotEqual(t, tt.expRecv, recv) // Make sure they were actually encrypted at first. } for k, v := range recv.SecureSettings { recv.SecureSettings[k] = decryptFn(v, service) } require.Equal(t, tt.expRecv, recv) }) } // Generate tests for each notification channel type. t.Run("secure settings migrations for each notifier type", func(t *testing.T) { notifiers := channels_config.GetAvailableNotifiers() t.Run("migrate notification channel secure settings to receiver secure settings", func(t *testing.T) { for _, notifier := range notifiers { nType := notifier.Type secureSettings, err := channels_config.GetSecretKeysForContactPointType(nType) require.NoError(t, err) t.Run(nType, func(t *testing.T) { service := NewTestMigrationService(t, sqlStore, nil) m := service.newOrgMigration(1) channel := gen(nType, func(channel *legacymodels.AlertNotification) { for _, key := range secureSettings { channel.SecureSettings[key] = []byte(legacyEncryptFn("secure " + key)) } }) recv, err := m.createReceiver(channel) require.NoError(t, err) require.Equal(t, nType, recv.Type) if len(secureSettings) > 0 { for _, key := range secureSettings { require.NotEqual(t, "secure "+key, recv.SecureSettings[key]) // Make sure they were actually encrypted at first. } } require.Len(t, recv.SecureSettings, len(secureSettings)) for _, key := range secureSettings { require.Equal(t, "secure "+key, decryptFn(recv.SecureSettings[key], service)) } }) } }) t.Run("for certain legacy channel types, migrate secure fields stored in settings to secure settings", func(t *testing.T) { for _, notifier := range notifiers { nType := notifier.Type secureSettings, ok := secureKeysToMigrate[nType] if !ok { continue } t.Run(nType, func(t *testing.T) { service := NewTestMigrationService(t, sqlStore, nil) m := service.newOrgMigration(1) channel := gen(nType, func(channel *legacymodels.AlertNotification) { for _, key := range secureSettings { // Key difference to above. We store the secure settings in the settings field and expect // them to be migrated to secureSettings. channel.Settings.Set(key, "secure "+key) } }) recv, err := m.createReceiver(channel) require.NoError(t, err) require.Equal(t, nType, recv.Type) if len(secureSettings) > 0 { for _, key := range secureSettings { require.NotEqual(t, "secure "+key, recv.SecureSettings[key]) // Make sure they were actually encrypted at first. } } require.Len(t, recv.SecureSettings, len(secureSettings)) for _, key := range secureSettings { require.Equal(t, "secure "+key, decryptFn(recv.SecureSettings[key], service)) } }) } }) }) } func TestSetupAlertmanagerConfig(t *testing.T) { tc := []struct { name string channels []*legacymodels.AlertNotification expContactPairs []*migmodels.ContactPair expErr error }{ { name: "when given multiple notification channels migrate them to receivers", channels: []*legacymodels.AlertNotification{createNotChannel(t, "uid1", int64(1), "notifier1", false, 0), createNotChannel(t, "uid2", int64(2), "notifier2", false, 0)}, expContactPairs: []*migmodels.ContactPair{ { Channel: createNotChannel(t, "uid1", int64(1), "notifier1", false, 0), ContactPoint: createPostableGrafanaReceiver("uid1", "notifier1"), Route: &apimodels.Route{Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier1"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, }, { Channel: createNotChannel(t, "uid2", int64(2), "notifier2", false, 0), ContactPoint: createPostableGrafanaReceiver("uid2", "notifier2"), Route: &apimodels.Route{Receiver: "notifier2", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier2"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, }, }, }, { name: "when given default notification channels migrate them to a routes with catchall matcher", channels: []*legacymodels.AlertNotification{createNotChannel(t, "uid1", int64(1), "notifier1", false, 0), createNotChannel(t, "uid2", int64(2), "notifier2", true, 0)}, expContactPairs: []*migmodels.ContactPair{ { Channel: createNotChannel(t, "uid1", int64(1), "notifier1", false, 0), ContactPoint: createPostableGrafanaReceiver("uid1", "notifier1"), Route: &apimodels.Route{Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier1"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, }, { Channel: createNotChannel(t, "uid2", int64(2), "notifier2", true, 0), ContactPoint: createPostableGrafanaReceiver("uid2", "notifier2"), Route: &apimodels.Route{Receiver: "notifier2", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchRegexp, Name: model.AlertNameLabel, Value: ".+"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)}, }, }, }, { name: "when given notification channels with SendReminder true migrate them to a route with frequency set", channels: []*legacymodels.AlertNotification{createNotChannel(t, "uid1", int64(1), "notifier1", false, time.Duration(42)), createNotChannel(t, "uid2", int64(2), "notifier2", false, time.Duration(43))}, expContactPairs: []*migmodels.ContactPair{ { Channel: createNotChannel(t, "uid1", int64(1), "notifier1", false, time.Duration(42)), ContactPoint: createPostableGrafanaReceiver("uid1", "notifier1"), Route: &apimodels.Route{Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier1"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(42)}, }, { Channel: createNotChannel(t, "uid2", int64(2), "notifier2", false, time.Duration(43)), ContactPoint: createPostableGrafanaReceiver("uid2", "notifier2"), Route: &apimodels.Route{Receiver: "notifier2", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier2"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(43)}, }, }, }, } for _, tt := range tc { t.Run(tt.name, func(t *testing.T) { sqlStore := db.InitTestDB(t) service := NewTestMigrationService(t, sqlStore, nil) m := service.newOrgMigration(1) pairs, err := m.migrateChannels(tt.channels) if tt.expErr != nil { require.Error(t, err) require.EqualError(t, err, tt.expErr.Error()) return } require.NoError(t, err) require.Lenf(t, pairs, len(tt.expContactPairs), "Unexpected number of migrated channels: %v", len(pairs)) opts := []cmp.Option{ cmpopts.IgnoreUnexported(labels.Matcher{}), cmpopts.IgnoreFields(legacymodels.AlertNotification{}, "Settings"), cmpopts.SortSlices(func(a, b *migmodels.ContactPair) bool { return a.Channel.ID < b.Channel.ID }), } if !cmp.Equal(pairs, tt.expContactPairs, opts...) { t.Errorf("Unexpected Config: %v", cmp.Diff(pairs, tt.expContactPairs, opts...)) } }) } } func createPostableGrafanaReceiver(uid string, name string) *apimodels.PostableGrafanaReceiver { return &apimodels.PostableGrafanaReceiver{ UID: uid, Type: "email", Name: name, Settings: apimodels.RawMessage("{}"), SecureSettings: map[string]string{}, } } func durationPointer(d model.Duration) *model.Duration { return &d }