1import React from 'react';
2import { locationService, setDataSourceSrv } from '@grafana/runtime';
3import { render, waitFor } from '@testing-library/react';
4import { Provider } from 'react-redux';
5import { Router } from 'react-router-dom';
6import {
7  AlertManagerCortexConfig,
8  AlertManagerDataSourceJsonData,
9  AlertManagerImplementation,
10  Route,
11} from 'app/plugins/datasource/alertmanager/types';
12import { configureStore } from 'app/store/configureStore';
13import { typeAsJestMock } from 'test/helpers/typeAsJestMock';
14import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector';
15import AmRoutes from './AmRoutes';
16import { fetchAlertManagerConfig, fetchStatus, updateAlertManagerConfig } from './api/alertmanager';
17import { mockDataSource, MockDataSourceSrv, someCloudAlertManagerConfig, someCloudAlertManagerStatus } from './mocks';
18import { getAllDataSources } from './utils/config';
19import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
20import userEvent from '@testing-library/user-event';
21import { selectOptionInTest } from '@grafana/ui';
22import { ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants';
23
24jest.mock('./api/alertmanager');
25jest.mock('./utils/config');
26
27const mocks = {
28  getAllDataSourcesMock: typeAsJestMock(getAllDataSources),
29
30  api: {
31    fetchAlertManagerConfig: typeAsJestMock(fetchAlertManagerConfig),
32    updateAlertManagerConfig: typeAsJestMock(updateAlertManagerConfig),
33    fetchStatus: typeAsJestMock(fetchStatus),
34  },
35};
36
37const renderAmRoutes = (alertManagerSourceName?: string) => {
38  const store = configureStore();
39  locationService.push(location);
40
41  locationService.push(
42    '/alerting/routes' + (alertManagerSourceName ? `?${ALERTMANAGER_NAME_QUERY_KEY}=${alertManagerSourceName}` : '')
43  );
44
45  return render(
46    <Provider store={store}>
47      <Router history={locationService.getHistory()}>
48        <AmRoutes />
49      </Router>
50    </Provider>
51  );
52};
53
54const dataSources = {
55  am: mockDataSource({
56    name: 'Alertmanager',
57    type: DataSourceType.Alertmanager,
58  }),
59  promAlertManager: mockDataSource<AlertManagerDataSourceJsonData>({
60    name: 'PromManager',
61    type: DataSourceType.Alertmanager,
62    jsonData: {
63      implementation: AlertManagerImplementation.prometheus,
64    },
65  }),
66};
67
68const ui = {
69  rootReceiver: byTestId('am-routes-root-receiver'),
70  rootGroupBy: byTestId('am-routes-root-group-by'),
71  rootTimings: byTestId('am-routes-root-timings'),
72  row: byTestId('am-routes-row'),
73
74  rootRouteContainer: byTestId('am-root-route-container'),
75
76  editButton: byRole('button', { name: 'Edit' }),
77  saveButton: byRole('button', { name: 'Save' }),
78
79  editRouteButton: byLabelText('Edit route'),
80  deleteRouteButton: byLabelText('Delete route'),
81  newPolicyButton: byRole('button', { name: /New policy/ }),
82  newPolicyCTAButton: byRole('button', { name: /New specific policy/ }),
83
84  receiverSelect: byTestId('am-receiver-select'),
85  groupSelect: byTestId('am-group-select'),
86
87  groupWaitContainer: byTestId('am-group-wait'),
88  groupIntervalContainer: byTestId('am-group-interval'),
89  groupRepeatContainer: byTestId('am-repeat-interval'),
90};
91
92describe('AmRoutes', () => {
93  const subroutes: Route[] = [
94    {
95      match: {
96        sub1matcher1: 'sub1value1',
97        sub1matcher2: 'sub1value2',
98      },
99      match_re: {
100        sub1matcher3: 'sub1value3',
101        sub1matcher4: 'sub1value4',
102      },
103      group_by: ['sub1group1', 'sub1group2'],
104      receiver: 'a-receiver',
105      continue: true,
106      group_wait: '3s',
107      group_interval: '2m',
108      repeat_interval: '1s',
109      routes: [
110        {
111          match: {
112            sub1sub1matcher1: 'sub1sub1value1',
113            sub1sub1matcher2: 'sub1sub1value2',
114          },
115          match_re: {
116            sub1sub1matcher3: 'sub1sub1value3',
117            sub1sub1matcher4: 'sub1sub1value4',
118          },
119          group_by: ['sub1sub1group1', 'sub1sub1group2'],
120          receiver: 'another-receiver',
121        },
122        {
123          match: {
124            sub1sub2matcher1: 'sub1sub2value1',
125            sub1sub2matcher2: 'sub1sub2value2',
126          },
127          match_re: {
128            sub1sub2matcher3: 'sub1sub2value3',
129            sub1sub2matcher4: 'sub1sub2value4',
130          },
131          group_by: ['sub1sub2group1', 'sub1sub2group2'],
132          receiver: 'another-receiver',
133        },
134      ],
135    },
136    {
137      match: {
138        sub2matcher1: 'sub2value1',
139        sub2matcher2: 'sub2value2',
140      },
141      match_re: {
142        sub2matcher3: 'sub2value3',
143        sub2matcher4: 'sub2value4',
144      },
145      receiver: 'another-receiver',
146    },
147  ];
148
149  const simpleRoute: Route = {
150    receiver: 'simple-receiver',
151    matchers: ['hello=world', 'foo!=bar'],
152  };
153
154  const rootRoute: Route = {
155    receiver: 'default-receiver',
156    group_by: ['a-group', 'another-group'],
157    group_wait: '1s',
158    group_interval: '2m',
159    repeat_interval: '3d',
160    routes: subroutes,
161  };
162
163  beforeEach(() => {
164    mocks.getAllDataSourcesMock.mockReturnValue(Object.values(dataSources));
165    setDataSourceSrv(new MockDataSourceSrv(dataSources));
166  });
167
168  afterEach(() => {
169    jest.resetAllMocks();
170
171    setDataSourceSrv(undefined as any);
172  });
173
174  it('loads and shows routes', async () => {
175    mocks.api.fetchAlertManagerConfig.mockResolvedValue({
176      alertmanager_config: {
177        route: rootRoute,
178        receivers: [
179          {
180            name: 'default-receiver',
181          },
182          {
183            name: 'a-receiver',
184          },
185          {
186            name: 'another-receiver',
187          },
188        ],
189      },
190      template_files: {},
191    });
192
193    await renderAmRoutes();
194
195    await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalledTimes(1));
196
197    expect(ui.rootReceiver.get()).toHaveTextContent(rootRoute.receiver!);
198    expect(ui.rootGroupBy.get()).toHaveTextContent(rootRoute.group_by!.join(', '));
199    const rootTimings = ui.rootTimings.get();
200    expect(rootTimings).toHaveTextContent(rootRoute.group_wait!);
201    expect(rootTimings).toHaveTextContent(rootRoute.group_interval!);
202    expect(rootTimings).toHaveTextContent(rootRoute.repeat_interval!);
203
204    const rows = await ui.row.findAll();
205    expect(rows).toHaveLength(2);
206
207    subroutes.forEach((route, index) => {
208      Object.entries(route.match ?? {}).forEach(([label, value]) => {
209        expect(rows[index]).toHaveTextContent(`${label}=${value}`);
210      });
211
212      Object.entries(route.match_re ?? {}).forEach(([label, value]) => {
213        expect(rows[index]).toHaveTextContent(`${label}=~${value}`);
214      });
215
216      if (route.group_by) {
217        expect(rows[index]).toHaveTextContent(route.group_by.join(', '));
218      }
219
220      if (route.receiver) {
221        expect(rows[index]).toHaveTextContent(route.receiver);
222      }
223    });
224  });
225
226  it('can edit root route if one is already defined', async () => {
227    const defaultConfig: AlertManagerCortexConfig = {
228      alertmanager_config: {
229        receivers: [{ name: 'default' }, { name: 'critical' }],
230        route: {
231          receiver: 'default',
232          group_by: ['alertname'],
233        },
234        templates: [],
235      },
236      template_files: {},
237    };
238    const currentConfig = { current: defaultConfig };
239    mocks.api.updateAlertManagerConfig.mockImplementation((amSourceName, newConfig) => {
240      currentConfig.current = newConfig;
241      return Promise.resolve();
242    });
243
244    mocks.api.fetchAlertManagerConfig.mockImplementation(() => {
245      return Promise.resolve(currentConfig.current);
246    });
247
248    await renderAmRoutes();
249    expect(await ui.rootReceiver.find()).toHaveTextContent('default');
250    expect(ui.rootGroupBy.get()).toHaveTextContent('alertname');
251
252    // open root route for editing
253    const rootRouteContainer = await ui.rootRouteContainer.find();
254    userEvent.click(ui.editButton.get(rootRouteContainer));
255
256    // configure receiver & group by
257    const receiverSelect = await ui.receiverSelect.find();
258    await clickSelectOption(receiverSelect, 'critical');
259
260    const groupSelect = ui.groupSelect.get();
261    userEvent.type(byRole('textbox').get(groupSelect), 'namespace{enter}');
262
263    // configure timing intervals
264    userEvent.click(byText('Timing options').get(rootRouteContainer));
265
266    await updateTiming(ui.groupWaitContainer.get(), '1', 'Minutes');
267    await updateTiming(ui.groupIntervalContainer.get(), '4', 'Minutes');
268    await updateTiming(ui.groupRepeatContainer.get(), '5', 'Hours');
269
270    //save
271    userEvent.click(ui.saveButton.get(rootRouteContainer));
272
273    // wait for it to go out of edit mode
274    await waitFor(() => expect(ui.editButton.query(rootRouteContainer)).not.toBeInTheDocument());
275
276    // check that appropriate api calls were made
277    expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalledTimes(3);
278    expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledTimes(1);
279    expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, {
280      alertmanager_config: {
281        receivers: [{ name: 'default' }, { name: 'critical' }],
282        route: {
283          continue: false,
284          group_by: ['alertname', 'namespace'],
285          receiver: 'critical',
286          routes: [],
287          group_interval: '4m',
288          group_wait: '1m',
289          repeat_interval: '5h',
290        },
291        templates: [],
292      },
293      template_files: {},
294    });
295
296    // check that new config values are rendered
297    await waitFor(() => expect(ui.rootReceiver.query()).toHaveTextContent('critical'));
298    expect(ui.rootGroupBy.get()).toHaveTextContent('alertname, namespace');
299  });
300
301  it('can edit root route if one is not defined yet', async () => {
302    mocks.api.fetchAlertManagerConfig.mockResolvedValue({
303      alertmanager_config: {
304        receivers: [{ name: 'default' }],
305      },
306      template_files: {},
307    });
308
309    await renderAmRoutes();
310
311    // open root route for editing
312    const rootRouteContainer = await ui.rootRouteContainer.find();
313    userEvent.click(ui.editButton.get(rootRouteContainer));
314
315    // configure receiver & group by
316    const receiverSelect = await ui.receiverSelect.find();
317    await clickSelectOption(receiverSelect, 'default');
318
319    const groupSelect = ui.groupSelect.get();
320    userEvent.type(byRole('textbox').get(groupSelect), 'severity{enter}');
321    userEvent.type(byRole('textbox').get(groupSelect), 'namespace{enter}');
322    //save
323    userEvent.click(ui.saveButton.get(rootRouteContainer));
324
325    // wait for it to go out of edit mode
326    await waitFor(() => expect(ui.editButton.query(rootRouteContainer)).not.toBeInTheDocument());
327
328    // check that appropriate api calls were made
329    expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalledTimes(3);
330    expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledTimes(1);
331    expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, {
332      alertmanager_config: {
333        receivers: [{ name: 'default' }],
334        route: {
335          continue: false,
336          group_by: ['severity', 'namespace'],
337          receiver: 'default',
338          routes: [],
339        },
340      },
341      template_files: {},
342    });
343  });
344
345  it('Show error message if loading Alertmanager config fails', async () => {
346    mocks.api.fetchAlertManagerConfig.mockRejectedValue({
347      status: 500,
348      data: {
349        message: "Alertmanager has exploded. it's gone. Forget about it.",
350      },
351    });
352    await renderAmRoutes();
353    await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalledTimes(1));
354    expect(await byText("Alertmanager has exploded. it's gone. Forget about it.").find()).toBeInTheDocument();
355    expect(ui.rootReceiver.query()).not.toBeInTheDocument();
356    expect(ui.editButton.query()).not.toBeInTheDocument();
357  });
358
359  it('Converts matchers to object_matchers for grafana alertmanager', async () => {
360    const defaultConfig: AlertManagerCortexConfig = {
361      alertmanager_config: {
362        receivers: [{ name: 'default' }, { name: 'critical' }],
363        route: {
364          continue: false,
365          receiver: 'default',
366          group_by: ['alertname'],
367          routes: [simpleRoute],
368          group_interval: '4m',
369          group_wait: '1m',
370          repeat_interval: '5h',
371        },
372        templates: [],
373      },
374      template_files: {},
375    };
376
377    const currentConfig = { current: defaultConfig };
378    mocks.api.updateAlertManagerConfig.mockImplementation((amSourceName, newConfig) => {
379      currentConfig.current = newConfig;
380      return Promise.resolve();
381    });
382
383    mocks.api.fetchAlertManagerConfig.mockImplementation(() => {
384      return Promise.resolve(currentConfig.current);
385    });
386
387    await renderAmRoutes(GRAFANA_RULES_SOURCE_NAME);
388    expect(await ui.rootReceiver.find()).toHaveTextContent('default');
389    expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled();
390
391    // Toggle a save to test new object_matchers
392    const rootRouteContainer = await ui.rootRouteContainer.find();
393    userEvent.click(ui.editButton.get(rootRouteContainer));
394    userEvent.click(ui.saveButton.get(rootRouteContainer));
395
396    await waitFor(() => expect(ui.editButton.query(rootRouteContainer)).not.toBeInTheDocument());
397
398    expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled();
399    expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, {
400      alertmanager_config: {
401        receivers: [{ name: 'default' }, { name: 'critical' }],
402        route: {
403          continue: false,
404          group_by: ['alertname'],
405          group_interval: '4m',
406          group_wait: '1m',
407          receiver: 'default',
408          repeat_interval: '5h',
409          routes: [
410            {
411              continue: false,
412              group_by: [],
413              object_matchers: [
414                ['hello', '=', 'world'],
415                ['foo', '!=', 'bar'],
416              ],
417              receiver: 'simple-receiver',
418              routes: [],
419            },
420          ],
421        },
422        templates: [],
423      },
424      template_files: {},
425    });
426  });
427
428  it('Keeps matchers for non-grafana alertmanager sources', async () => {
429    const defaultConfig: AlertManagerCortexConfig = {
430      alertmanager_config: {
431        receivers: [{ name: 'default' }, { name: 'critical' }],
432        route: {
433          continue: false,
434          receiver: 'default',
435          group_by: ['alertname'],
436          routes: [simpleRoute],
437          group_interval: '4m',
438          group_wait: '1m',
439          repeat_interval: '5h',
440        },
441        templates: [],
442      },
443      template_files: {},
444    };
445
446    const currentConfig = { current: defaultConfig };
447    mocks.api.updateAlertManagerConfig.mockImplementation((amSourceName, newConfig) => {
448      currentConfig.current = newConfig;
449      return Promise.resolve();
450    });
451
452    mocks.api.fetchAlertManagerConfig.mockImplementation(() => {
453      return Promise.resolve(currentConfig.current);
454    });
455
456    await renderAmRoutes(dataSources.am.name);
457    expect(await ui.rootReceiver.find()).toHaveTextContent('default');
458    expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled();
459
460    // Toggle a save to test new object_matchers
461    const rootRouteContainer = await ui.rootRouteContainer.find();
462    userEvent.click(ui.editButton.get(rootRouteContainer));
463    userEvent.click(ui.saveButton.get(rootRouteContainer));
464
465    await waitFor(() => expect(ui.editButton.query(rootRouteContainer)).not.toBeInTheDocument());
466
467    expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled();
468    expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith(dataSources.am.name, {
469      alertmanager_config: {
470        receivers: [{ name: 'default' }, { name: 'critical' }],
471        route: {
472          continue: false,
473          group_by: ['alertname'],
474          group_interval: '4m',
475          group_wait: '1m',
476          matchers: [],
477          receiver: 'default',
478          repeat_interval: '5h',
479          routes: [
480            {
481              continue: false,
482              group_by: [],
483              matchers: ['hello=world', 'foo!=bar'],
484              receiver: 'simple-receiver',
485              routes: [],
486            },
487          ],
488        },
489        templates: [],
490      },
491      template_files: {},
492    });
493  });
494
495  it('Prometheus Alertmanager routes cannot be edited', async () => {
496    mocks.api.fetchStatus.mockResolvedValue({
497      ...someCloudAlertManagerStatus,
498      config: someCloudAlertManagerConfig.alertmanager_config,
499    });
500    await renderAmRoutes(dataSources.promAlertManager.name);
501    const rootRouteContainer = await ui.rootRouteContainer.find();
502    expect(ui.editButton.query(rootRouteContainer)).not.toBeInTheDocument();
503    const rows = await ui.row.findAll();
504    expect(rows).toHaveLength(2);
505    expect(ui.editRouteButton.query()).not.toBeInTheDocument();
506    expect(ui.deleteRouteButton.query()).not.toBeInTheDocument();
507    expect(ui.saveButton.query()).not.toBeInTheDocument();
508
509    expect(mocks.api.fetchAlertManagerConfig).not.toHaveBeenCalled();
510    expect(mocks.api.fetchStatus).toHaveBeenCalledTimes(1);
511  });
512
513  it('Prometheus Alertmanager has no CTA button if there are no specific policies', async () => {
514    mocks.api.fetchStatus.mockResolvedValue({
515      ...someCloudAlertManagerStatus,
516      config: {
517        ...someCloudAlertManagerConfig.alertmanager_config,
518        route: {
519          ...someCloudAlertManagerConfig.alertmanager_config.route,
520          routes: undefined,
521        },
522      },
523    });
524    await renderAmRoutes(dataSources.promAlertManager.name);
525    const rootRouteContainer = await ui.rootRouteContainer.find();
526    expect(ui.editButton.query(rootRouteContainer)).not.toBeInTheDocument();
527    expect(ui.newPolicyCTAButton.query()).not.toBeInTheDocument();
528    expect(mocks.api.fetchAlertManagerConfig).not.toHaveBeenCalled();
529    expect(mocks.api.fetchStatus).toHaveBeenCalledTimes(1);
530  });
531});
532
533const clickSelectOption = async (selectElement: HTMLElement, optionText: string): Promise<void> => {
534  userEvent.click(byRole('textbox').get(selectElement));
535  await selectOptionInTest(selectElement, optionText);
536};
537
538const updateTiming = async (selectElement: HTMLElement, value: string, timeUnit: string): Promise<void> => {
539  const inputs = byRole('textbox').queryAll(selectElement);
540  expect(inputs).toHaveLength(2);
541  userEvent.type(inputs[0], value);
542  userEvent.click(inputs[1]);
543  await selectOptionInTest(selectElement, timeUnit);
544};
545