From 72182e02a4b5dbff3f0176cf85643e6aaf16b117 Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Fri, 5 Jan 2024 17:26:15 -0500 Subject: [PATCH] Alerting: Mute timing service tests (#79817) split tests for mute timing service to functions for each method this makes it clear the scope of tests --- pkg/services/ngalert/provisioning/config.go | 5 + .../ngalert/provisioning/mute_timings.go | 2 +- .../ngalert/provisioning/mute_timings_test.go | 952 ++++++++++-------- pkg/services/ngalert/provisioning/testing.go | 41 +- 4 files changed, 580 insertions(+), 420 deletions(-) diff --git a/pkg/services/ngalert/provisioning/config.go b/pkg/services/ngalert/provisioning/config.go index 90e58819593..f711b02d718 100644 --- a/pkg/services/ngalert/provisioning/config.go +++ b/pkg/services/ngalert/provisioning/config.go @@ -49,6 +49,11 @@ func getLastConfiguration(ctx context.Context, orgID int64, store AMConfigStore) }, nil } +type alertmanagerConfigStore interface { + Get(ctx context.Context, orgID int64) (*cfgRevision, error) + Save(ctx context.Context, revision *cfgRevision, orgID int64) error +} + type alertmanagerConfigStoreImpl struct { store AMConfigStore } diff --git a/pkg/services/ngalert/provisioning/mute_timings.go b/pkg/services/ngalert/provisioning/mute_timings.go index 0ca7a57bfbc..bab15e4680b 100644 --- a/pkg/services/ngalert/provisioning/mute_timings.go +++ b/pkg/services/ngalert/provisioning/mute_timings.go @@ -12,7 +12,7 @@ import ( ) type MuteTimingService struct { - configStore *alertmanagerConfigStoreImpl + configStore alertmanagerConfigStore provenanceStore ProvisioningStore xact TransactionManager log log.Logger diff --git a/pkg/services/ngalert/provisioning/mute_timings_test.go b/pkg/services/ngalert/provisioning/mute_timings_test.go index 12cdc0a4d96..ee348d1f8f0 100644 --- a/pkg/services/ngalert/provisioning/mute_timings_test.go +++ b/pkg/services/ngalert/provisioning/mute_timings_test.go @@ -2,11 +2,14 @@ package provisioning import ( "context" + "errors" "fmt" + "slices" "testing" "github.com/prometheus/alertmanager/config" - mock "github.com/stretchr/testify/mock" + "github.com/prometheus/alertmanager/timeinterval" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" @@ -14,27 +17,58 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/models" ) -func TestMuteTimingService(t *testing.T) { +func TestGetMuteTimings(t *testing.T) { + orgID := int64(1) + revision := &cfgRevision{ + cfg: &definitions.PostableUserConfig{ + AlertmanagerConfig: definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + MuteTimeIntervals: []config.MuteTimeInterval{ + { + Name: "Test1", + TimeIntervals: nil, + }, + { + Name: "Test2", + TimeIntervals: nil, + }, + { + Name: "Test3", + TimeIntervals: nil, + }, + }, + }, + }, + }, + } + t.Run("service returns timings from config file", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimings, - }) + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return revision, nil + } result, err := sut.GetMuteTimings(context.Background(), 1) require.NoError(t, err) - require.Len(t, result, 1) - require.Equal(t, "asdf", result[0].Name) + require.Len(t, result, len(revision.cfg.AlertmanagerConfig.MuteTimeIntervals)) + require.Equal(t, "Test1", result[0].Name) + require.EqualValues(t, "", result[0].Provenance) + require.Equal(t, "Test2", result[1].Name) + require.EqualValues(t, "", result[1].Provenance) + require.Equal(t, "Test3", result[2].Name) + require.EqualValues(t, "", result[2].Provenance) + + require.Len(t, store.Calls, 1) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) }) t.Run("service returns empty list when config file contains no mute timings", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: defaultConfig, - }) + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: &definitions.PostableUserConfig{}}, nil + } result, err := sut.GetMuteTimings(context.Background(), 1) @@ -44,418 +78,500 @@ func TestMuteTimingService(t *testing.T) { t.Run("service propagates errors", func(t *testing.T) { t.Run("when unable to read config", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(nil, fmt.Errorf("failed")) - - _, err := sut.GetMuteTimings(context.Background(), 1) - - require.Error(t, err) - }) - - t.Run("when config is invalid", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: brokenConfig, - }) - - _, err := sut.GetMuteTimings(context.Background(), 1) - - require.Truef(t, ErrBadAlertmanagerConfiguration.Base.Is(err), "expected ErrBadAlertmanagerConfiguration but got %s", err.Error()) - }) - - t.Run("when no AM config in current org", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(nil, nil) - - _, err := sut.GetMuteTimings(context.Background(), 1) - - require.Truef(t, ErrNoAlertmanagerConfiguration.Is(err), "expected ErrNoAlertmanagerConfiguration but got %s", err.Error()) - }) - }) - - t.Run("creating mute timings", func(t *testing.T) { - t.Run("rejects mute timings that fail validation", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := definitions.MuteTimeInterval{ - MuteTimeInterval: config.MuteTimeInterval{ - Name: "", - }, + sut, store, _ := createMuteTimingSvcSut() + expected := fmt.Errorf("failed") + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return nil, expected } - _, err := sut.CreateMuteTiming(context.Background(), timing, 1) + _, err := sut.GetMuteTimings(context.Background(), orgID) - require.ErrorIs(t, err, ErrValidation) - }) - - t.Run("propagates errors", func(t *testing.T) { - t.Run("when unable to read config", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(nil, fmt.Errorf("failed")) - - _, err := sut.CreateMuteTiming(context.Background(), timing, 1) - - require.Error(t, err) - }) - - t.Run("when config is invalid", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: brokenConfig, - }) - - _, err := sut.CreateMuteTiming(context.Background(), timing, 1) - - require.Truef(t, ErrBadAlertmanagerConfiguration.Base.Is(err), "expected ErrBadAlertmanagerConfiguration but got %s", err.Error()) - }) - - t.Run("when no AM config in current org", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(nil, nil) - - _, err := sut.CreateMuteTiming(context.Background(), timing, 1) - - require.Truef(t, ErrNoAlertmanagerConfiguration.Is(err), "expected ErrNoAlertmanagerConfiguration but got %s", err.Error()) - }) - - t.Run("when provenance fails to save", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimings, - }) - sut.configStore.store.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.provenanceStore.(*MockProvisioningStore).EXPECT(). - SetProvenance(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(fmt.Errorf("failed to save provenance")) - - _, err := sut.CreateMuteTiming(context.Background(), timing, 1) - - require.ErrorContains(t, err, "failed to save provenance") - }) - - t.Run("when AM config fails to save", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimings, - }) - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(fmt.Errorf("failed to save configStore")) - sut.provenanceStore.(*MockProvisioningStore).EXPECT().SaveSucceeds() - - _, err := sut.CreateMuteTiming(context.Background(), timing, 1) - - require.ErrorContains(t, err, "failed to save config") - }) - }) - }) - - t.Run("updating mute timings", func(t *testing.T) { - t.Run("rejects mute timings that fail validation", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := definitions.MuteTimeInterval{ - MuteTimeInterval: config.MuteTimeInterval{ - Name: "", - }, - } - - _, err := sut.UpdateMuteTiming(context.Background(), timing, 1) - - require.ErrorIs(t, err, ErrValidation) - }) - - t.Run("returns nil if timing does not exist", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - timing.Name = "does not exist" - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimings, - }) - sut.configStore.store.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.provenanceStore.(*MockProvisioningStore).EXPECT().SaveSucceeds() - - updated, err := sut.UpdateMuteTiming(context.Background(), timing, 1) - - require.NoError(t, err) - require.Nil(t, updated) - }) - - t.Run("propagates errors", func(t *testing.T) { - t.Run("when unable to read config", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - timing.Name = "asdf" - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(nil, fmt.Errorf("failed")) - - _, err := sut.UpdateMuteTiming(context.Background(), timing, 1) - - require.Error(t, err) - }) - - t.Run("when config is invalid", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - timing.Name = "asdf" - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: brokenConfig, - }) - - _, err := sut.UpdateMuteTiming(context.Background(), timing, 1) - - require.Truef(t, ErrBadAlertmanagerConfiguration.Base.Is(err), "expected ErrBadAlertmanagerConfiguration but got %s", err.Error()) - }) - - t.Run("when no AM config in current org", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - timing.Name = "asdf" - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(nil, nil) - - _, err := sut.UpdateMuteTiming(context.Background(), timing, 1) - - require.Truef(t, ErrNoAlertmanagerConfiguration.Is(err), "expected ErrNoAlertmanagerConfiguration but got %s", err.Error()) - }) - - t.Run("when provenance fails to save", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - timing.Name = "asdf" - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimings, - }) - sut.configStore.store.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.provenanceStore.(*MockProvisioningStore).EXPECT(). - SetProvenance(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(fmt.Errorf("failed to save provenance")) - - _, err := sut.UpdateMuteTiming(context.Background(), timing, 1) - - require.ErrorContains(t, err, "failed to save provenance") - }) - - t.Run("when AM config fails to save", func(t *testing.T) { - sut := createMuteTimingSvcSut() - timing := createMuteTiming() - timing.Name = "asdf" - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimings, - }) - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(fmt.Errorf("failed to save config")) - sut.provenanceStore.(*MockProvisioningStore).EXPECT().SaveSucceeds() - - _, err := sut.UpdateMuteTiming(context.Background(), timing, 1) - - require.ErrorContains(t, err, "failed to save config") - }) - }) - }) - - t.Run("deleting mute timings", func(t *testing.T) { - t.Run("returns nil if timing does not exist", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimings, - }) - sut.configStore.store.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.provenanceStore.(*MockProvisioningStore).EXPECT().SaveSucceeds() - - err := sut.DeleteMuteTiming(context.Background(), "does not exist", 1) - - require.NoError(t, err) - }) - - t.Run("propagates errors", func(t *testing.T) { - t.Run("when unable to read config", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(nil, fmt.Errorf("failed")) - - err := sut.DeleteMuteTiming(context.Background(), "asdf", 1) - - require.Error(t, err) - }) - - t.Run("when config is invalid", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: brokenConfig, - }) - - err := sut.DeleteMuteTiming(context.Background(), "asdf", 1) - - require.Truef(t, ErrBadAlertmanagerConfiguration.Base.Is(err), "expected ErrBadAlertmanagerConfiguration but got %s", err.Error()) - }) - - t.Run("when no AM config in current org", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(nil, nil) - - err := sut.DeleteMuteTiming(context.Background(), "asdf", 1) - - require.Truef(t, ErrNoAlertmanagerConfiguration.Is(err), "expected ErrNoAlertmanagerConfiguration but got %s", err.Error()) - }) - - t.Run("when provenance fails to save", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimings, - }) - sut.configStore.store.(*MockAMConfigStore).EXPECT().SaveSucceeds() - sut.provenanceStore.(*MockProvisioningStore).EXPECT(). - DeleteProvenance(mock.Anything, mock.Anything, mock.Anything). - Return(fmt.Errorf("failed to save provenance")) - - err := sut.DeleteMuteTiming(context.Background(), "asdf", 1) - - require.ErrorContains(t, err, "failed to save provenance") - }) - - t.Run("when AM config fails to save", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimings, - }) - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything). - Return(fmt.Errorf("failed to save config")) - sut.provenanceStore.(*MockProvisioningStore).EXPECT().SaveSucceeds() - - err := sut.DeleteMuteTiming(context.Background(), "asdf", 1) - - require.ErrorContains(t, err, "failed to save config") - }) - - t.Run("when mute timing is used in route", func(t *testing.T) { - sut := createMuteTimingSvcSut() - sut.configStore.store.(*MockAMConfigStore).EXPECT(). - GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: configWithMuteTimingsInRoute, - }) - - err := sut.DeleteMuteTiming(context.Background(), "asdf", 1) - - require.Error(t, err) - }) + require.ErrorIs(t, err, expected) }) }) } -func createMuteTimingSvcSut() *MuteTimingService { +func TestCreateMuteTimings(t *testing.T) { + orgID := int64(1) + + initialConfig := func() *definitions.PostableUserConfig { + return &definitions.PostableUserConfig{ + TemplateFiles: nil, + AlertmanagerConfig: definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + MuteTimeIntervals: []config.MuteTimeInterval{ + { + Name: "TEST", + }, + }, + }, + Receivers: nil, + }, + } + } + + expected := config.MuteTimeInterval{ + Name: "Test", + TimeIntervals: []timeinterval.TimeInterval{ + { + Times: []timeinterval.TimeRange{ + { + StartMinute: 10, EndMinute: 60, + }, + }, + }, + }, + } + expectedProvenance := models.ProvenanceAPI + timing := definitions.MuteTimeInterval{ + MuteTimeInterval: expected, + Provenance: definitions.Provenance(expectedProvenance), + } + + t.Run("returns error if mute timings fail validation", func(t *testing.T) { + sut, _, _ := createMuteTimingSvcSut() + timing := definitions.MuteTimeInterval{ + MuteTimeInterval: config.MuteTimeInterval{ + Name: "", + }, + Provenance: definitions.Provenance(models.ProvenanceFile), + } + + _, err := sut.CreateMuteTiming(context.Background(), timing, orgID) + + require.ErrorIs(t, err, ErrValidation) + }) + + t.Run("returns error if mute timing with the name exists", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + + existing := initialConfig().AlertmanagerConfig.MuteTimeIntervals[0] + timing := definitions.MuteTimeInterval{ + MuteTimeInterval: existing, + Provenance: definitions.Provenance(models.ProvenanceFile), + } + + _, err := sut.CreateMuteTiming(context.Background(), timing, orgID) + + require.ErrorContains(t, err, "a mute timing with this name already exists") + }) + + t.Run("saves mute timing and provenance in a transaction", func(t *testing.T) { + sut, store, prov := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + store.SaveFn = func(ctx context.Context, revision *cfgRevision) error { + assertInTransaction(t, ctx) + return nil + } + prov.EXPECT().SetProvenance(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, _ models.Provisionable, _ int64, _ models.Provenance) error { + assertInTransaction(t, ctx) + return nil + }) + + result, err := sut.CreateMuteTiming(context.Background(), timing, orgID) + require.NoError(t, err) + + require.EqualValues(t, expected, result.MuteTimeInterval) + require.EqualValues(t, expectedProvenance, result.Provenance) + + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + require.Equal(t, "Save", store.Calls[1].Method) + require.Equal(t, orgID, store.Calls[1].Args[2]) + revision := store.Calls[1].Args[1].(*cfgRevision) + + expectedTimings := append(initialConfig().AlertmanagerConfig.MuteTimeIntervals, expected) + require.EqualValues(t, expectedTimings, revision.cfg.AlertmanagerConfig.MuteTimeIntervals) + + prov.AssertCalled(t, "SetProvenance", mock.Anything, &timing, orgID, expectedProvenance) + }) + + t.Run("propagates errors", func(t *testing.T) { + t.Run("when unable to read config", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + expectedErr := errors.New("test-err") + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return nil, expectedErr + } + _, err := sut.CreateMuteTiming(context.Background(), timing, orgID) + require.ErrorIs(t, err, expectedErr) + }) + + t.Run("when provenance fails to save", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + expectedErr := fmt.Errorf("failed to save provenance") + sut.provenanceStore.(*MockProvisioningStore).EXPECT(). + SetProvenance(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(expectedErr) + + _, err := sut.CreateMuteTiming(context.Background(), timing, orgID) + + require.ErrorIs(t, err, expectedErr) + + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + require.Equal(t, "Save", store.Calls[1].Method) + }) + + t.Run("when AM config fails to save", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + expectedErr := errors.New("test-err") + store.SaveFn = func(ctx context.Context, revision *cfgRevision) error { + return expectedErr + } + + _, err := sut.CreateMuteTiming(context.Background(), timing, orgID) + + require.ErrorIs(t, err, expectedErr) + + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + require.Equal(t, "Save", store.Calls[1].Method) + }) + }) +} + +func TestUpdateMuteTimings(t *testing.T) { + orgID := int64(1) + + initialConfig := func() *definitions.PostableUserConfig { + return &definitions.PostableUserConfig{ + TemplateFiles: nil, + AlertmanagerConfig: definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + MuteTimeIntervals: []config.MuteTimeInterval{ + { + Name: "Test", + }, + }, + }, + Receivers: nil, + }, + } + } + + expected := config.MuteTimeInterval{ + Name: "Test", + TimeIntervals: []timeinterval.TimeInterval{ + { + Times: []timeinterval.TimeRange{ + { + StartMinute: 10, EndMinute: 60, + }, + }, + }, + }, + } + expectedProvenance := models.ProvenanceAPI + timing := definitions.MuteTimeInterval{ + MuteTimeInterval: expected, + Provenance: definitions.Provenance(expectedProvenance), + } + + t.Run("rejects mute timings that fail validation", func(t *testing.T) { + sut, _, _ := createMuteTimingSvcSut() + timing := definitions.MuteTimeInterval{ + MuteTimeInterval: config.MuteTimeInterval{ + Name: "", + }, + Provenance: definitions.Provenance(models.ProvenanceFile), + } + + _, err := sut.UpdateMuteTiming(context.Background(), timing, orgID) + + require.ErrorIs(t, err, ErrValidation) + }) + + t.Run("returns nil if mute timing does not exist", func(t *testing.T) { + sut, store, prov := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + + timing := definitions.MuteTimeInterval{ + MuteTimeInterval: config.MuteTimeInterval{ + Name: "No-timing", + }, + Provenance: definitions.Provenance(models.ProvenanceFile), + } + + mt, err := sut.UpdateMuteTiming(context.Background(), timing, orgID) + require.NoError(t, err) + require.Nil(t, mt) + + require.Len(t, store.Calls, 1) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + prov.AssertNotCalled(t, "SetProvenance", mock.Anything, &timing, orgID, expectedProvenance) + }) + + t.Run("saves mute timing and provenance in a transaction", func(t *testing.T) { + sut, store, prov := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + store.SaveFn = func(ctx context.Context, revision *cfgRevision) error { + assertInTransaction(t, ctx) + return nil + } + prov.EXPECT().SetProvenance(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, _ models.Provisionable, _ int64, _ models.Provenance) error { + assertInTransaction(t, ctx) + return nil + }) + + result, err := sut.UpdateMuteTiming(context.Background(), timing, orgID) + require.NoError(t, err) + + require.EqualValues(t, expected, result.MuteTimeInterval) + require.EqualValues(t, expectedProvenance, result.Provenance) + + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + require.Equal(t, "Save", store.Calls[1].Method) + require.Equal(t, orgID, store.Calls[1].Args[2]) + revision := store.Calls[1].Args[1].(*cfgRevision) + + require.EqualValues(t, []config.MuteTimeInterval{expected}, revision.cfg.AlertmanagerConfig.MuteTimeIntervals) + + prov.AssertCalled(t, "SetProvenance", mock.Anything, &timing, orgID, expectedProvenance) + }) + + t.Run("propagates errors", func(t *testing.T) { + t.Run("when unable to read config", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + expectedErr := errors.New("test-err") + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return nil, expectedErr + } + _, err := sut.UpdateMuteTiming(context.Background(), timing, orgID) + require.ErrorIs(t, err, expectedErr) + }) + + t.Run("when provenance fails to save", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + expectedErr := fmt.Errorf("failed to save provenance") + sut.provenanceStore.(*MockProvisioningStore).EXPECT(). + SetProvenance(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(expectedErr) + + _, err := sut.UpdateMuteTiming(context.Background(), timing, orgID) + + require.ErrorIs(t, err, expectedErr) + + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + require.Equal(t, "Save", store.Calls[1].Method) + }) + + t.Run("when AM config fails to save", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + expectedErr := errors.New("test-err") + store.SaveFn = func(ctx context.Context, revision *cfgRevision) error { + return expectedErr + } + + _, err := sut.UpdateMuteTiming(context.Background(), timing, orgID) + + require.ErrorIs(t, err, expectedErr) + + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + require.Equal(t, "Save", store.Calls[1].Method) + }) + }) +} + +func TestDeleteMuteTimings(t *testing.T) { + orgID := int64(1) + + timingToDelete := config.MuteTimeInterval{Name: "unused-timing"} + usedTiming := "used-timing" + initialConfig := func() *definitions.PostableUserConfig { + return &definitions.PostableUserConfig{ + TemplateFiles: nil, + AlertmanagerConfig: definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + Route: &definitions.Route{ + MuteTimeIntervals: []string{usedTiming}, + }, + MuteTimeIntervals: []config.MuteTimeInterval{ + { + Name: usedTiming, + }, + timingToDelete, + }, + }, + Receivers: nil, + }, + } + } + + t.Run("re-saves config and deletes provenance if mute timing does not exist", func(t *testing.T) { + sut, store, prov := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + prov.EXPECT().DeleteProvenance(mock.Anything, mock.Anything, mock.Anything).Return(nil) + + err := sut.DeleteMuteTiming(context.Background(), "no-timing", orgID) + require.NoError(t, err) + + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + require.Equal(t, "Save", store.Calls[1].Method) + require.Equal(t, orgID, store.Calls[1].Args[2]) + revision := store.Calls[1].Args[1].(*cfgRevision) + + require.EqualValues(t, initialConfig().AlertmanagerConfig.MuteTimeIntervals, revision.cfg.AlertmanagerConfig.MuteTimeIntervals) + + prov.AssertCalled(t, "DeleteProvenance", mock.Anything, &definitions.MuteTimeInterval{MuteTimeInterval: config.MuteTimeInterval{Name: "no-timing"}}, orgID) + }) + + t.Run("returns error if mute timing is used", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + + err := sut.DeleteMuteTiming(context.Background(), usedTiming, orgID) + + require.ErrorContains(t, err, "is currently used by a notification policy") + + require.Len(t, store.Calls, 1) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + }) + + t.Run("deletes mute timing and provenance in transaction", func(t *testing.T) { + sut, store, prov := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + store.SaveFn = func(ctx context.Context, revision *cfgRevision) error { + assertInTransaction(t, ctx) + return nil + } + prov.EXPECT().DeleteProvenance(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, _ models.Provisionable, _ int64) error { + assertInTransaction(t, ctx) + return nil + }) + + err := sut.DeleteMuteTiming(context.Background(), timingToDelete.Name, orgID) + require.NoError(t, err) + + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + require.Equal(t, "Save", store.Calls[1].Method) + require.Equal(t, orgID, store.Calls[1].Args[2]) + revision := store.Calls[1].Args[1].(*cfgRevision) + + expectedMuteTimings := slices.DeleteFunc(initialConfig().AlertmanagerConfig.MuteTimeIntervals, func(interval config.MuteTimeInterval) bool { + return interval.Name == timingToDelete.Name + }) + require.EqualValues(t, expectedMuteTimings, revision.cfg.AlertmanagerConfig.MuteTimeIntervals) + + prov.AssertCalled(t, "DeleteProvenance", mock.Anything, &definitions.MuteTimeInterval{MuteTimeInterval: timingToDelete}, orgID) + }) + + t.Run("propagates errors", func(t *testing.T) { + t.Run("when unable to read config", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + expectedErr := errors.New("test-err") + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return nil, expectedErr + } + err := sut.DeleteMuteTiming(context.Background(), timingToDelete.Name, orgID) + require.ErrorIs(t, err, expectedErr) + }) + + t.Run("when provenance fails to save", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + expectedErr := fmt.Errorf("failed to save provenance") + sut.provenanceStore.(*MockProvisioningStore).EXPECT(). + DeleteProvenance(mock.Anything, mock.Anything, mock.Anything). + Return(expectedErr) + + err := sut.DeleteMuteTiming(context.Background(), timingToDelete.Name, orgID) + + require.ErrorIs(t, err, expectedErr) + + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + require.Equal(t, "Save", store.Calls[1].Method) + }) + + t.Run("when AM config fails to save", func(t *testing.T) { + sut, store, _ := createMuteTimingSvcSut() + store.GetFn = func(ctx context.Context, orgID int64) (*cfgRevision, error) { + return &cfgRevision{cfg: initialConfig()}, nil + } + expectedErr := errors.New("test-err") + store.SaveFn = func(ctx context.Context, revision *cfgRevision) error { + return expectedErr + } + + err := sut.DeleteMuteTiming(context.Background(), timingToDelete.Name, orgID) + + require.ErrorIs(t, err, expectedErr) + + require.Len(t, store.Calls, 2) + require.Equal(t, "Get", store.Calls[0].Method) + require.Equal(t, orgID, store.Calls[0].Args[1]) + + require.Equal(t, "Save", store.Calls[1].Method) + }) + }) +} + +func createMuteTimingSvcSut() (*MuteTimingService, *alertmanagerConfigStoreFake, *MockProvisioningStore) { + store := &alertmanagerConfigStoreFake{} + prov := &MockProvisioningStore{} return &MuteTimingService{ - configStore: &alertmanagerConfigStoreImpl{store: &MockAMConfigStore{}}, - provenanceStore: &MockProvisioningStore{}, + configStore: store, + provenanceStore: prov, xact: newNopTransactionManager(), log: log.NewNopLogger(), - } + }, store, prov } - -func createMuteTiming() definitions.MuteTimeInterval { - return definitions.MuteTimeInterval{ - MuteTimeInterval: config.MuteTimeInterval{ - Name: "interval", - }, - } -} - -var configWithMuteTimings = ` -{ - "template_files": { - "a": "template" - }, - "alertmanager_config": { - "route": { - "receiver": "grafana-default-email" - }, - "mute_time_intervals": [{ - "name": "asdf", - "time_intervals": [{ - "times": [], - "weekdays": ["monday"] - }] - }], - "receivers": [{ - "name": "grafana-default-email", - "grafana_managed_receiver_configs": [{ - "uid": "", - "name": "email receiver", - "type": "email", - "isDefault": true, - "settings": { - "addresses": "" - } - }] - }] - } -} -` - -var configWithMuteTimingsInRoute = ` -{ - "template_files": { - "a": "template" - }, - "alertmanager_config": { - "route": { - "receiver": "grafana-default-email", - "routes": [ - { - "receiver": "grafana-default-email", - "mute_time_intervals": ["asdf"] - } - ] - }, - "mute_time_intervals": [{ - "name": "asdf", - "time_intervals": [{ - "times": [], - "weekdays": ["monday"] - }] - }], - "receivers": [{ - "name": "grafana-default-email", - "grafana_managed_receiver_configs": [{ - "uid": "", - "name": "email receiver", - "type": "email", - "isDefault": true, - "settings": { - "addresses": "" - } - }] - }] - } -} -` diff --git a/pkg/services/ngalert/provisioning/testing.go b/pkg/services/ngalert/provisioning/testing.go index 121601a292d..a935805d664 100644 --- a/pkg/services/ngalert/provisioning/testing.go +++ b/pkg/services/ngalert/provisioning/testing.go @@ -5,7 +5,9 @@ import ( "crypto/md5" "fmt" "strings" + "testing" + "github.com/stretchr/testify/assert" mock "github.com/stretchr/testify/mock" "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -141,8 +143,12 @@ func newNopTransactionManager() *NopTransactionManager { return &NopTransactionManager{} } +func assertInTransaction(t *testing.T, ctx context.Context) { + assert.Truef(t, ctx.Value(NopTransactionManager{}) != nil, "Expected to be executed in transaction but there is none") +} + func (n *NopTransactionManager) InTransaction(ctx context.Context, work func(ctx context.Context) error) error { - return work(ctx) + return work(context.WithValue(ctx, NopTransactionManager{}, struct{}{})) } func (m *MockAMConfigStore_Expecter) GetsConfig(ac models.AlertConfiguration) *MockAMConfigStore_Expecter { @@ -185,3 +191,36 @@ func (m *MockQuotaChecker_Expecter) LimitExceeded() *MockQuotaChecker_Expecter { m.CheckQuotaReached(mock.Anything, mock.Anything, mock.Anything).Return(true, nil) return m } + +type methodCall struct { + Method string + Args []interface{} +} + +type alertmanagerConfigStoreFake struct { + Calls []methodCall + GetFn func(ctx context.Context, orgID int64) (*cfgRevision, error) + SaveFn func(ctx context.Context, revision *cfgRevision) error +} + +func (a *alertmanagerConfigStoreFake) Get(ctx context.Context, orgID int64) (*cfgRevision, error) { + a.Calls = append(a.Calls, methodCall{ + Method: "Get", + Args: []interface{}{ctx, orgID}, + }) + if a.GetFn != nil { + return a.GetFn(ctx, orgID) + } + return nil, nil +} + +func (a *alertmanagerConfigStoreFake) Save(ctx context.Context, revision *cfgRevision, orgID int64) error { + a.Calls = append(a.Calls, methodCall{ + Method: "Save", + Args: []interface{}{ctx, revision, orgID}, + }) + if a.SaveFn != nil { + return a.SaveFn(ctx, revision) + } + return nil +}