import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; import { Router } from 'react-router-dom'; import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector'; import { locationService, setDataSourceSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import { AlertManagerCortexConfig, AlertManagerDataSourceJsonData, AlertManagerImplementation, MuteTimeInterval, Route, } from 'app/plugins/datasource/alertmanager/types'; import { configureStore } from 'app/store/configureStore'; import { AccessControlAction } from 'app/types'; import AmRoutes from './AmRoutes'; import { fetchAlertManagerConfig, fetchStatus, updateAlertManagerConfig } from './api/alertmanager'; import { mockDataSource, MockDataSourceSrv, someCloudAlertManagerConfig, someCloudAlertManagerStatus } from './mocks'; import { getAllDataSources } from './utils/config'; import { ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; jest.mock('./api/alertmanager'); jest.mock('./utils/config'); jest.mock('app/core/services/context_srv'); const mocks = { getAllDataSourcesMock: jest.mocked(getAllDataSources), api: { fetchAlertManagerConfig: jest.mocked(fetchAlertManagerConfig), updateAlertManagerConfig: jest.mocked(updateAlertManagerConfig), fetchStatus: jest.mocked(fetchStatus), }, contextSrv: jest.mocked(contextSrv), }; const renderAmRoutes = (alertManagerSourceName?: string) => { const store = configureStore(); locationService.push(location); locationService.push( '/alerting/routes' + (alertManagerSourceName ? `?${ALERTMANAGER_NAME_QUERY_KEY}=${alertManagerSourceName}` : '') ); return render( ); }; const dataSources = { am: mockDataSource({ name: 'Alertmanager', type: DataSourceType.Alertmanager, }), promAlertManager: mockDataSource({ name: 'PromManager', type: DataSourceType.Alertmanager, jsonData: { implementation: AlertManagerImplementation.prometheus, }, }), }; const ui = { rootReceiver: byTestId('am-routes-root-receiver'), rootGroupBy: byTestId('am-routes-root-group-by'), rootTimings: byTestId('am-routes-root-timings'), row: byTestId('am-routes-row'), rootRouteContainer: byTestId('am-root-route-container'), editButton: byRole('button', { name: 'Edit' }), saveButton: byRole('button', { name: 'Save' }), editRouteButton: byLabelText('Edit route'), deleteRouteButton: byLabelText('Delete route'), newPolicyButton: byRole('button', { name: /New policy/ }), newPolicyCTAButton: byRole('button', { name: /New specific policy/ }), savePolicyButton: byRole('button', { name: /save policy/i }), receiverSelect: byTestId('am-receiver-select'), groupSelect: byTestId('am-group-select'), muteTimingSelect: byTestId('am-mute-timing-select'), groupWaitContainer: byTestId('am-group-wait'), groupIntervalContainer: byTestId('am-group-interval'), groupRepeatContainer: byTestId('am-repeat-interval'), confirmDeleteModal: byRole('dialog'), confirmDeleteButton: byLabelText('Confirm Modal Danger Button'), }; describe('AmRoutes', () => { const subroutes: Route[] = [ { match: { sub1matcher1: 'sub1value1', sub1matcher2: 'sub1value2', }, match_re: { sub1matcher3: 'sub1value3', sub1matcher4: 'sub1value4', }, group_by: ['sub1group1', 'sub1group2'], receiver: 'a-receiver', continue: true, group_wait: '3s', group_interval: '2m', repeat_interval: '1s', routes: [ { match: { sub1sub1matcher1: 'sub1sub1value1', sub1sub1matcher2: 'sub1sub1value2', }, match_re: { sub1sub1matcher3: 'sub1sub1value3', sub1sub1matcher4: 'sub1sub1value4', }, group_by: ['sub1sub1group1', 'sub1sub1group2'], receiver: 'another-receiver', }, { match: { sub1sub2matcher1: 'sub1sub2value1', sub1sub2matcher2: 'sub1sub2value2', }, match_re: { sub1sub2matcher3: 'sub1sub2value3', sub1sub2matcher4: 'sub1sub2value4', }, group_by: ['sub1sub2group1', 'sub1sub2group2'], receiver: 'another-receiver', }, ], }, { match: { sub2matcher1: 'sub2value1', sub2matcher2: 'sub2value2', }, match_re: { sub2matcher3: 'sub2value3', sub2matcher4: 'sub2value4', }, receiver: 'another-receiver', }, ]; const emptyRoute: Route = {}; const simpleRoute: Route = { receiver: 'simple-receiver', matchers: ['hello=world', 'foo!=bar'], }; const rootRoute: Route = { receiver: 'default-receiver', group_by: ['a-group', 'another-group'], group_wait: '1s', group_interval: '2m', repeat_interval: '3d', routes: subroutes, }; const muteInterval: MuteTimeInterval = { name: 'default-mute', time_intervals: [ { times: [{ start_time: '12:00', end_time: '24:00' }], weekdays: ['monday:friday'], days_of_month: ['1:7', '-1:-7'], months: ['january:june'], years: ['2020:2022'], }, ], }; beforeEach(() => { mocks.getAllDataSourcesMock.mockReturnValue(Object.values(dataSources)); mocks.contextSrv.hasAccess.mockImplementation(() => true); mocks.contextSrv.hasPermission.mockImplementation(() => true); mocks.contextSrv.evaluatePermission.mockImplementation(() => []); setDataSourceSrv(new MockDataSourceSrv(dataSources)); }); afterEach(() => { jest.resetAllMocks(); setDataSourceSrv(undefined as any); }); it('loads and shows routes', async () => { mocks.api.fetchAlertManagerConfig.mockResolvedValue({ alertmanager_config: { route: rootRoute, receivers: [ { name: 'default-receiver', }, { name: 'a-receiver', }, { name: 'another-receiver', }, ], }, template_files: {}, }); await renderAmRoutes(); await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalledTimes(1)); expect(ui.rootReceiver.get()).toHaveTextContent(rootRoute.receiver!); expect(ui.rootGroupBy.get()).toHaveTextContent(rootRoute.group_by!.join(', ')); const rootTimings = ui.rootTimings.get(); expect(rootTimings).toHaveTextContent(rootRoute.group_wait!); expect(rootTimings).toHaveTextContent(rootRoute.group_interval!); expect(rootTimings).toHaveTextContent(rootRoute.repeat_interval!); const rows = await ui.row.findAll(); expect(rows).toHaveLength(2); subroutes.forEach((route, index) => { Object.entries(route.match ?? {}).forEach(([label, value]) => { expect(rows[index]).toHaveTextContent(`${label}=${value}`); }); Object.entries(route.match_re ?? {}).forEach(([label, value]) => { expect(rows[index]).toHaveTextContent(`${label}=~${value}`); }); if (route.group_by) { expect(rows[index]).toHaveTextContent(route.group_by.join(', ')); } if (route.receiver) { expect(rows[index]).toHaveTextContent(route.receiver); } }); }); it('can edit root route if one is already defined', async () => { const defaultConfig: AlertManagerCortexConfig = { alertmanager_config: { receivers: [{ name: 'default' }, { name: 'critical' }], route: { receiver: 'default', group_by: ['alertname'], }, templates: [], }, template_files: {}, }; const currentConfig = { current: defaultConfig }; mocks.api.updateAlertManagerConfig.mockImplementation((amSourceName, newConfig) => { currentConfig.current = newConfig; return Promise.resolve(); }); mocks.api.fetchAlertManagerConfig.mockImplementation(() => { return Promise.resolve(currentConfig.current); }); await renderAmRoutes(); expect(await ui.rootReceiver.find()).toHaveTextContent('default'); expect(ui.rootGroupBy.get()).toHaveTextContent('alertname'); // open root route for editing const rootRouteContainer = await ui.rootRouteContainer.find(); await userEvent.click(ui.editButton.get(rootRouteContainer)); // configure receiver & group by const receiverSelect = await ui.receiverSelect.find(); await clickSelectOption(receiverSelect, 'critical'); const groupSelect = ui.groupSelect.get(); await userEvent.type(byRole('combobox').get(groupSelect), 'namespace{enter}'); // configure timing intervals await userEvent.click(byText('Timing options').get(rootRouteContainer)); await updateTiming(ui.groupWaitContainer.get(), '1', 'Minutes'); await updateTiming(ui.groupIntervalContainer.get(), '4', 'Minutes'); await updateTiming(ui.groupRepeatContainer.get(), '5', 'Hours'); //save await userEvent.click(ui.saveButton.get(rootRouteContainer)); // wait for it to go out of edit mode await waitFor(() => expect(ui.editButton.query(rootRouteContainer)).not.toBeInTheDocument()); // check that appropriate api calls were made expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalledTimes(3); expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledTimes(1); expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, { alertmanager_config: { receivers: [{ name: 'default' }, { name: 'critical' }], route: { continue: false, group_by: ['alertname', 'namespace'], receiver: 'critical', routes: [], group_interval: '4m', group_wait: '1m', repeat_interval: '5h', mute_time_intervals: [], }, templates: [], }, template_files: {}, }); // check that new config values are rendered await waitFor(() => expect(ui.rootReceiver.query()).toHaveTextContent('critical')); expect(ui.rootGroupBy.get()).toHaveTextContent('alertname, namespace'); }); it('can edit root route if one is not defined yet', async () => { mocks.api.fetchAlertManagerConfig.mockResolvedValue({ alertmanager_config: { receivers: [{ name: 'default' }], }, template_files: {}, }); await renderAmRoutes(); // open root route for editing const rootRouteContainer = await ui.rootRouteContainer.find(); await userEvent.click(ui.editButton.get(rootRouteContainer)); // configure receiver & group by const receiverSelect = await ui.receiverSelect.find(); await clickSelectOption(receiverSelect, 'default'); const groupSelect = ui.groupSelect.get(); await userEvent.type(byRole('combobox').get(groupSelect), 'severity{enter}'); await userEvent.type(byRole('combobox').get(groupSelect), 'namespace{enter}'); //save await userEvent.click(ui.saveButton.get(rootRouteContainer)); // wait for it to go out of edit mode await waitFor(() => expect(ui.editButton.query(rootRouteContainer)).not.toBeInTheDocument()); // check that appropriate api calls were made expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalledTimes(3); expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledTimes(1); expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, { alertmanager_config: { receivers: [{ name: 'default' }], route: { continue: false, group_by: ['severity', 'namespace'], receiver: 'default', routes: [], mute_time_intervals: [], }, }, template_files: {}, }); }); it('hides create and edit button if user does not have permission', () => { mocks.contextSrv.hasAccess.mockImplementation((action) => [AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsRead].includes( action as AccessControlAction ) ); renderAmRoutes(); expect(ui.newPolicyButton.query()).not.toBeInTheDocument(); expect(ui.editButton.query()).not.toBeInTheDocument(); }); it('Show error message if loading Alertmanager config fails', async () => { mocks.api.fetchAlertManagerConfig.mockRejectedValue({ status: 500, data: { message: "Alertmanager has exploded. it's gone. Forget about it.", }, }); await renderAmRoutes(); await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalledTimes(1)); expect(await byText("Alertmanager has exploded. it's gone. Forget about it.").find()).toBeInTheDocument(); expect(ui.rootReceiver.query()).not.toBeInTheDocument(); expect(ui.editButton.query()).not.toBeInTheDocument(); }); it('Converts matchers to object_matchers for grafana alertmanager', async () => { const defaultConfig: AlertManagerCortexConfig = { alertmanager_config: { receivers: [{ name: 'default' }, { name: 'critical' }], route: { continue: false, receiver: 'default', group_by: ['alertname'], routes: [simpleRoute], group_interval: '4m', group_wait: '1m', repeat_interval: '5h', }, templates: [], }, template_files: {}, }; const currentConfig = { current: defaultConfig }; mocks.api.updateAlertManagerConfig.mockImplementation((amSourceName, newConfig) => { currentConfig.current = newConfig; return Promise.resolve(); }); mocks.api.fetchAlertManagerConfig.mockImplementation(() => { return Promise.resolve(currentConfig.current); }); await renderAmRoutes(GRAFANA_RULES_SOURCE_NAME); expect(await ui.rootReceiver.find()).toHaveTextContent('default'); expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled(); // Toggle a save to test new object_matchers const rootRouteContainer = await ui.rootRouteContainer.find(); await userEvent.click(ui.editButton.get(rootRouteContainer)); await userEvent.click(ui.saveButton.get(rootRouteContainer)); await waitFor(() => expect(ui.editButton.query(rootRouteContainer)).not.toBeInTheDocument()); expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled(); expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, { alertmanager_config: { receivers: [{ name: 'default' }, { name: 'critical' }], route: { continue: false, group_by: ['alertname'], group_interval: '4m', group_wait: '1m', receiver: 'default', repeat_interval: '5h', mute_time_intervals: [], routes: [ { continue: false, group_by: [], object_matchers: [ ['hello', '=', 'world'], ['foo', '!=', 'bar'], ], receiver: 'simple-receiver', mute_time_intervals: [], routes: [], }, ], }, templates: [], }, template_files: {}, }); }); it('Should be able to delete an empty route', async () => { const routeConfig = { continue: false, receiver: 'default', group_by: ['alertname'], routes: [emptyRoute], group_interval: '4m', group_wait: '1m', repeat_interval: '5h', mute_time_intervals: [], }; const defaultConfig: AlertManagerCortexConfig = { alertmanager_config: { receivers: [{ name: 'default' }, { name: 'critical' }], route: routeConfig, templates: [], }, template_files: {}, }; mocks.api.fetchAlertManagerConfig.mockImplementation(() => { return Promise.resolve(defaultConfig); }); mocks.api.updateAlertManagerConfig.mockResolvedValue(Promise.resolve()); await renderAmRoutes(GRAFANA_RULES_SOURCE_NAME); expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled(); const deleteButtons = await ui.deleteRouteButton.findAll(); expect(deleteButtons).toHaveLength(1); await userEvent.click(deleteButtons[0]); const confirmDeleteButton = ui.confirmDeleteButton.get(ui.confirmDeleteModal.get()); expect(confirmDeleteButton).toBeInTheDocument(); await userEvent.click(confirmDeleteButton); expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith<[string, AlertManagerCortexConfig]>( GRAFANA_RULES_SOURCE_NAME, { ...defaultConfig, alertmanager_config: { ...defaultConfig.alertmanager_config, route: { ...routeConfig, routes: [], }, }, } ); }); it('Keeps matchers for non-grafana alertmanager sources', async () => { const defaultConfig: AlertManagerCortexConfig = { alertmanager_config: { receivers: [{ name: 'default' }, { name: 'critical' }], route: { continue: false, receiver: 'default', group_by: ['alertname'], routes: [simpleRoute], group_interval: '4m', group_wait: '1m', repeat_interval: '5h', }, templates: [], }, template_files: {}, }; const currentConfig = { current: defaultConfig }; mocks.api.updateAlertManagerConfig.mockImplementation((amSourceName, newConfig) => { currentConfig.current = newConfig; return Promise.resolve(); }); mocks.api.fetchAlertManagerConfig.mockImplementation(() => { return Promise.resolve(currentConfig.current); }); await renderAmRoutes(dataSources.am.name); expect(await ui.rootReceiver.find()).toHaveTextContent('default'); expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled(); // Toggle a save to test new object_matchers const rootRouteContainer = await ui.rootRouteContainer.find(); await userEvent.click(ui.editButton.get(rootRouteContainer)); await userEvent.click(ui.saveButton.get(rootRouteContainer)); await waitFor(() => expect(ui.editButton.query(rootRouteContainer)).not.toBeInTheDocument()); expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled(); expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith(dataSources.am.name, { alertmanager_config: { receivers: [{ name: 'default' }, { name: 'critical' }], route: { continue: false, group_by: ['alertname'], group_interval: '4m', group_wait: '1m', matchers: [], receiver: 'default', repeat_interval: '5h', mute_time_intervals: [], routes: [ { continue: false, group_by: [], matchers: ['hello=world', 'foo!=bar'], receiver: 'simple-receiver', routes: [], mute_time_intervals: [], }, ], }, templates: [], }, template_files: {}, }); }); it('Prometheus Alertmanager routes cannot be edited', async () => { mocks.api.fetchStatus.mockResolvedValue({ ...someCloudAlertManagerStatus, config: someCloudAlertManagerConfig.alertmanager_config, }); await renderAmRoutes(dataSources.promAlertManager.name); const rootRouteContainer = await ui.rootRouteContainer.find(); expect(ui.editButton.query(rootRouteContainer)).not.toBeInTheDocument(); const rows = await ui.row.findAll(); expect(rows).toHaveLength(2); expect(ui.editRouteButton.query()).not.toBeInTheDocument(); expect(ui.deleteRouteButton.query()).not.toBeInTheDocument(); expect(ui.saveButton.query()).not.toBeInTheDocument(); expect(mocks.api.fetchAlertManagerConfig).not.toHaveBeenCalled(); expect(mocks.api.fetchStatus).toHaveBeenCalledTimes(1); }); it('Prometheus Alertmanager has no CTA button if there are no specific policies', async () => { mocks.api.fetchStatus.mockResolvedValue({ ...someCloudAlertManagerStatus, config: { ...someCloudAlertManagerConfig.alertmanager_config, route: { ...someCloudAlertManagerConfig.alertmanager_config.route, routes: undefined, }, }, }); await renderAmRoutes(dataSources.promAlertManager.name); const rootRouteContainer = await ui.rootRouteContainer.find(); expect(ui.editButton.query(rootRouteContainer)).not.toBeInTheDocument(); expect(ui.newPolicyCTAButton.query()).not.toBeInTheDocument(); expect(mocks.api.fetchAlertManagerConfig).not.toHaveBeenCalled(); expect(mocks.api.fetchStatus).toHaveBeenCalledTimes(1); }); it('Can add a mute timing to a route', async () => { const defaultConfig: AlertManagerCortexConfig = { alertmanager_config: { receivers: [{ name: 'default' }, { name: 'critical' }], route: { continue: false, receiver: 'default', group_by: ['alertname'], routes: [simpleRoute], group_interval: '4m', group_wait: '1m', repeat_interval: '5h', }, templates: [], mute_time_intervals: [muteInterval], }, template_files: {}, }; const currentConfig = { current: defaultConfig }; mocks.api.updateAlertManagerConfig.mockImplementation((amSourceName, newConfig) => { currentConfig.current = newConfig; return Promise.resolve(); }); mocks.api.fetchAlertManagerConfig.mockResolvedValue(defaultConfig); await renderAmRoutes(dataSources.am.name); const rows = await ui.row.findAll(); expect(rows).toHaveLength(1); await userEvent.click(ui.editRouteButton.get(rows[0])); const muteTimingSelect = ui.muteTimingSelect.get(); await clickSelectOption(muteTimingSelect, 'default-mute'); expect(muteTimingSelect).toHaveTextContent('default-mute'); const savePolicyButton = ui.savePolicyButton.get(); expect(savePolicyButton).toBeInTheDocument(); await userEvent.click(savePolicyButton); await waitFor(() => expect(savePolicyButton).not.toBeInTheDocument()); expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled(); expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith(dataSources.am.name, { ...defaultConfig, alertmanager_config: { ...defaultConfig.alertmanager_config, route: { ...defaultConfig.alertmanager_config.route, mute_time_intervals: [], matchers: [], routes: [ { ...simpleRoute, mute_time_intervals: [muteInterval.name], routes: [], continue: false, group_by: [], }, ], }, }, }); }); }); const clickSelectOption = async (selectElement: HTMLElement, optionText: string): Promise => { await userEvent.click(byRole('combobox').get(selectElement)); await selectOptionInTest(selectElement, optionText); }; const updateTiming = async (selectElement: HTMLElement, value: string, timeUnit: string): Promise => { const input = byRole('textbox').get(selectElement); const select = byRole('combobox').get(selectElement); await userEvent.clear(input); await userEvent.type(input, value); await userEvent.click(select); await selectOptionInTest(selectElement, timeUnit); };