]> git.apps.os.sepia.ceph.com Git - ceph.git/blob
b331140d655dcb550cee513b1171324a7b604210
[ceph.git] /
1 import { HttpClientTestingModule } from '@angular/common/http/testing';
2 import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing';
3 import { ReactiveFormsModule } from '@angular/forms';
4 import { By } from '@angular/platform-browser';
5 import { ActivatedRoute, Router, Routes } from '@angular/router';
6 import { RouterTestingModule } from '@angular/router/testing';
7
8 import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
9 import * as _ from 'lodash';
10 import { BsDatepickerDirective, BsDatepickerModule } from 'ngx-bootstrap/datepicker';
11 import { BsModalService } from 'ngx-bootstrap/modal';
12 import { ToastrModule } from 'ngx-toastr';
13 import { of, throwError } from 'rxjs';
14
15 import {
16   configureTestBed,
17   FixtureHelper,
18   FormHelper,
19   i18nProviders,
20   PrometheusHelper
21 } from '../../../../../testing/unit-test-helper';
22 import { NotFoundComponent } from '../../../../core/not-found/not-found.component';
23 import { PrometheusService } from '../../../../shared/api/prometheus.service';
24 import { NotificationType } from '../../../../shared/enum/notification-type.enum';
25 import { CdFormGroup } from '../../../../shared/forms/cd-form-group';
26 import { AlertmanagerSilence } from '../../../../shared/models/alertmanager-silence';
27 import { Permission } from '../../../../shared/models/permissions';
28 import { AuthStorageService } from '../../../../shared/services/auth-storage.service';
29 import { NotificationService } from '../../../../shared/services/notification.service';
30 import { SharedModule } from '../../../../shared/shared.module';
31 import { SilenceFormComponent } from './silence-form.component';
32
33 describe('SilenceFormComponent', () => {
34   // SilenceFormComponent specific
35   let component: SilenceFormComponent;
36   let fixture: ComponentFixture<SilenceFormComponent>;
37   let form: CdFormGroup;
38   // Spied on
39   let prometheusService: PrometheusService;
40   let authStorageService: AuthStorageService;
41   let notificationService: NotificationService;
42   let router: Router;
43   // Spies
44   let rulesSpy: jasmine.Spy;
45   let ifPrometheusSpy: jasmine.Spy;
46   // Helper
47   let prometheus: PrometheusHelper;
48   let formHelper: FormHelper;
49   let fixtureH: FixtureHelper;
50   let params: Record<string, any>;
51   // Date mocking related
52   let originalDate: any;
53   const baseTime = new Date('2022-02-22T00:00:00');
54   const beginningDate = new Date('2022-02-22T00:00:12.35');
55
56   const routes: Routes = [{ path: '404', component: NotFoundComponent }];
57   configureTestBed({
58     declarations: [NotFoundComponent, SilenceFormComponent],
59     imports: [
60       HttpClientTestingModule,
61       RouterTestingModule.withRoutes(routes),
62       BsDatepickerModule.forRoot(),
63       SharedModule,
64       ToastrModule.forRoot(),
65       NgbTooltipModule,
66       ReactiveFormsModule
67     ],
68     providers: [
69       i18nProviders,
70       {
71         provide: ActivatedRoute,
72         useValue: { params: { subscribe: (fn: Function) => fn(params) } }
73       }
74     ]
75   });
76
77   const createMatcher = (name: string, value: any, isRegex: boolean) => ({ name, value, isRegex });
78
79   const addMatcher = (name: string, value: any, isRegex: boolean) =>
80     component['setMatcher'](createMatcher(name, value, isRegex));
81
82   const callInit = () =>
83     fixture.ngZone.run(() => {
84       component['init']();
85     });
86
87   const changeAction = (action: string) => {
88     const modes = {
89       add: '/monitoring/silence/add',
90       alertAdd: '/monitoring/silence/add/someAlert',
91       recreate: '/monitoring/silence/recreate/someExpiredId',
92       edit: '/monitoring/silence/edit/someNotExpiredId'
93     };
94     Object.defineProperty(router, 'url', { value: modes[action] });
95     callInit();
96   };
97
98   beforeEach(() => {
99     params = {};
100
101     originalDate = Date;
102     spyOn(global, 'Date').and.callFake((arg) => (arg ? new originalDate(arg) : beginningDate));
103
104     prometheus = new PrometheusHelper();
105     prometheusService = TestBed.inject(PrometheusService);
106     spyOn(prometheusService, 'getAlerts').and.callFake(() =>
107       of([prometheus.createAlert('alert0')])
108     );
109     ifPrometheusSpy = spyOn(prometheusService, 'ifPrometheusConfigured').and.callFake((fn) => fn());
110     rulesSpy = spyOn(prometheusService, 'getRules').and.callFake(() =>
111       of({
112         groups: [
113           {
114             file: '',
115             interval: 0,
116             name: '',
117             rules: [
118               prometheus.createRule('alert0', 'someSeverity', [prometheus.createAlert('alert0')]),
119               prometheus.createRule('alert1', 'someSeverity', []),
120               prometheus.createRule('alert2', 'someOtherSeverity', [
121                 prometheus.createAlert('alert2')
122               ])
123             ]
124           }
125         ]
126       })
127     );
128
129     router = TestBed.inject(Router);
130
131     notificationService = TestBed.inject(NotificationService);
132     spyOn(notificationService, 'show').and.stub();
133
134     authStorageService = TestBed.inject(AuthStorageService);
135     spyOn(authStorageService, 'getUsername').and.returnValue('someUser');
136
137     fixture = TestBed.createComponent(SilenceFormComponent);
138     fixtureH = new FixtureHelper(fixture);
139     component = fixture.componentInstance;
140     form = component.form;
141     formHelper = new FormHelper(form);
142     fixture.detectChanges();
143   });
144
145   it('should create', () => {
146     expect(component).toBeTruthy();
147     expect(_.isArray(component.rules)).toBeTruthy();
148   });
149
150   it('should have set the logged in user name as creator', () => {
151     expect(component.form.getValue('createdBy')).toBe('someUser');
152   });
153
154   it('should call disablePrometheusConfig on error calling getRules', () => {
155     spyOn(prometheusService, 'disablePrometheusConfig');
156     rulesSpy.and.callFake(() => throwError({}));
157     callInit();
158     expect(component.rules).toEqual([]);
159     expect(prometheusService.disablePrometheusConfig).toHaveBeenCalled();
160   });
161
162   it('should remind user if prometheus is not set when it is not configured', () => {
163     ifPrometheusSpy.and.callFake((_x: any, fn: Function) => fn());
164     callInit();
165     expect(component.rules).toEqual([]);
166     expect(notificationService.show).toHaveBeenCalledWith(
167       NotificationType.info,
168       'Please add your Prometheus host to the dashboard configuration and refresh the page',
169       undefined,
170       undefined,
171       'Prometheus'
172     );
173   });
174
175   describe('redirect not allowed users', () => {
176     let prometheusPermissions: Permission;
177     let navigateSpy: jasmine.Spy;
178
179     const expectRedirect = (action: string, redirected: boolean) => {
180       changeAction(action);
181       expect(router.navigate).toHaveBeenCalledTimes(redirected ? 1 : 0);
182       if (redirected) {
183         expect(router.navigate).toHaveBeenCalledWith(['/404']);
184       }
185       navigateSpy.calls.reset();
186     };
187
188     beforeEach(() => {
189       navigateSpy = spyOn(router, 'navigate').and.stub();
190       spyOn(authStorageService, 'getPermissions').and.callFake(() => ({
191         prometheus: prometheusPermissions
192       }));
193     });
194
195     it('redirects to 404 if not allowed', () => {
196       prometheusPermissions = new Permission(['delete', 'read']);
197       expectRedirect('add', true);
198       expectRedirect('alertAdd', true);
199     });
200
201     it('redirects if user does not have minimum permissions to create silences', () => {
202       prometheusPermissions = new Permission(['update', 'delete', 'read']);
203       expectRedirect('add', true);
204       prometheusPermissions = new Permission(['update', 'delete', 'create']);
205       expectRedirect('recreate', true);
206     });
207
208     it('redirects if user does not have minimum permissions to update silences', () => {
209       prometheusPermissions = new Permission(['create', 'delete', 'read']);
210       expectRedirect('edit', true);
211       prometheusPermissions = new Permission(['create', 'delete', 'update']);
212       expectRedirect('edit', true);
213     });
214
215     it('does not redirect if user has minimum permissions to create silences', () => {
216       prometheusPermissions = new Permission(['create', 'read']);
217       expectRedirect('add', false);
218       expectRedirect('alertAdd', false);
219       expectRedirect('recreate', false);
220     });
221
222     it('does not redirect if user has minimum permissions to update silences', () => {
223       prometheusPermissions = new Permission(['update', 'read']);
224       expectRedirect('edit', false);
225     });
226   });
227
228   describe('choose the right action', () => {
229     const expectMode = (routerMode: string, edit: boolean, recreate: boolean, action: string) => {
230       changeAction(routerMode);
231       expect(component.recreate).toBe(recreate);
232       expect(component.edit).toBe(edit);
233       expect(component.action).toBe(action);
234     };
235
236     beforeEach(() => {
237       spyOn(prometheusService, 'getSilences').and.callFake((p) =>
238         of([prometheus.createSilence(p.id)])
239       );
240     });
241
242     it('should have no special action activate by default', () => {
243       expectMode('add', false, false, 'Create');
244       expect(prometheusService.getSilences).not.toHaveBeenCalled();
245       expect(component.form.value).toEqual({
246         comment: null,
247         createdBy: 'someUser',
248         duration: '2h',
249         startsAt: baseTime,
250         endsAt: new Date('2022-02-22T02:00:00')
251       });
252     });
253
254     it('should be in edit action if route includes edit', () => {
255       params = { id: 'someNotExpiredId' };
256       expectMode('edit', true, false, 'Edit');
257       expect(prometheusService.getSilences).toHaveBeenCalledWith(params);
258       expect(component.form.value).toEqual({
259         comment: `A comment for ${params.id}`,
260         createdBy: `Creator of ${params.id}`,
261         duration: '1d',
262         startsAt: new Date('2022-02-22T22:22:00'),
263         endsAt: new Date('2022-02-23T22:22:00')
264       });
265       expect(component.matchers).toEqual([createMatcher('job', 'someJob', true)]);
266     });
267
268     it('should be in recreation action if route includes recreate', () => {
269       params = { id: 'someExpiredId' };
270       expectMode('recreate', false, true, 'Recreate');
271       expect(prometheusService.getSilences).toHaveBeenCalledWith(params);
272       expect(component.form.value).toEqual({
273         comment: `A comment for ${params.id}`,
274         createdBy: `Creator of ${params.id}`,
275         duration: '2h',
276         startsAt: baseTime,
277         endsAt: new Date('2022-02-22T02:00:00')
278       });
279       expect(component.matchers).toEqual([createMatcher('job', 'someJob', true)]);
280     });
281
282     it('adds matchers based on the label object of the alert with the given id', () => {
283       params = { id: 'someAlert' };
284       expectMode('alertAdd', false, false, 'Create');
285       expect(prometheusService.getSilences).not.toHaveBeenCalled();
286       expect(prometheusService.getAlerts).toHaveBeenCalled();
287       expect(component.matchers).toEqual([
288         createMatcher('alertname', 'alert0', false),
289         createMatcher('instance', 'someInstance', false),
290         createMatcher('job', 'someJob', false),
291         createMatcher('severity', 'someSeverity', false)
292       ]);
293       expect(component.matcherMatch).toEqual({
294         cssClass: 'has-success',
295         status: 'Matches 1 rule with 1 active alert.'
296       });
297     });
298   });
299
300   describe('time', () => {
301     // Can't be used to set accurate UTC dates in unit tests as Date uses timezones,
302     // this means the UTC time changes depending on the timezone you are in.
303     const changeDatePicker = (el: any, text: string) => {
304       el.triggerEventHandler('change', { target: { value: text } });
305     };
306     const getDatePicker = (i: number) =>
307       fixture.debugElement.queryAll(By.directive(BsDatepickerDirective))[i];
308     const changeEndDate = (text: string) => changeDatePicker(getDatePicker(1), text);
309     const changeStartDate = (text: string) => changeDatePicker(getDatePicker(0), text);
310
311     it('have all dates set at beginning', () => {
312       expect(form.getValue('startsAt')).toEqual(baseTime);
313       expect(form.getValue('duration')).toBe('2h');
314       expect(form.getValue('endsAt')).toEqual(new Date('2022-02-22T02:00:00'));
315     });
316
317     describe('on start date change', () => {
318       it('changes end date on start date change if it exceeds it', fakeAsync(() => {
319         changeStartDate('2022-02-28T 04:05');
320         expect(form.getValue('duration')).toEqual('2h');
321         expect(form.getValue('endsAt')).toEqual(new Date('2022-02-28T06:05:00'));
322
323         changeStartDate('2022-12-31T 22:00');
324         expect(form.getValue('duration')).toEqual('2h');
325         expect(form.getValue('endsAt')).toEqual(new Date('2023-01-01T00:00:00'));
326       }));
327
328       it('changes duration if start date does not exceed end date ', fakeAsync(() => {
329         changeStartDate('2022-02-22T 00:45');
330         expect(form.getValue('duration')).toEqual('1h 15m');
331         expect(form.getValue('endsAt')).toEqual(new Date('2022-02-22T02:00:00'));
332       }));
333
334       it('should raise invalid start date error', fakeAsync(() => {
335         changeStartDate('No valid date');
336         formHelper.expectError('startsAt', 'bsDate');
337         expect(form.getValue('startsAt').toString()).toBe('Invalid Date');
338         expect(form.getValue('endsAt')).toEqual(new Date('2022-02-22T02:00:00'));
339       }));
340     });
341
342     describe('on duration change', () => {
343       it('changes end date if duration is changed', () => {
344         formHelper.setValue('duration', '15m');
345         expect(form.getValue('endsAt')).toEqual(new Date('2022-02-22T00:15'));
346         formHelper.setValue('duration', '5d 23h');
347         expect(form.getValue('endsAt')).toEqual(new Date('2022-02-27T23:00'));
348       });
349     });
350
351     describe('on end date change', () => {
352       it('changes duration on end date change if it exceeds start date', fakeAsync(() => {
353         changeEndDate('2022-02-28T 04:05');
354         expect(form.getValue('duration')).toEqual('6d 4h 5m');
355         expect(form.getValue('startsAt')).toEqual(baseTime);
356       }));
357
358       it('changes start date if end date happens before it', fakeAsync(() => {
359         changeEndDate('2022-02-21T 02:00');
360         expect(form.getValue('duration')).toEqual('2h');
361         expect(form.getValue('startsAt')).toEqual(new Date('2022-02-21T00:00:00'));
362       }));
363
364       it('should raise invalid end date error', fakeAsync(() => {
365         changeEndDate('No valid date');
366         formHelper.expectError('endsAt', 'bsDate');
367         expect(form.getValue('endsAt').toString()).toBe('Invalid Date');
368         expect(form.getValue('startsAt')).toEqual(baseTime);
369       }));
370     });
371   });
372
373   it('should have a creator field', () => {
374     formHelper.expectValid('createdBy');
375     formHelper.expectErrorChange('createdBy', '', 'required');
376     formHelper.expectValidChange('createdBy', 'Mighty FSM');
377   });
378
379   it('should have a comment field', () => {
380     formHelper.expectError('comment', 'required');
381     formHelper.expectValidChange('comment', 'A pretty long comment');
382   });
383
384   it('should be a valid form if all inputs are filled and at least one matcher was added', () => {
385     expect(form.valid).toBeFalsy();
386     formHelper.expectValidChange('createdBy', 'Mighty FSM');
387     formHelper.expectValidChange('comment', 'A pretty long comment');
388     addMatcher('job', 'someJob', false);
389     expect(form.valid).toBeTruthy();
390   });
391
392   describe('matchers', () => {
393     const expectMatch = (helpText: string) => {
394       expect(fixtureH.getText('#match-state')).toBe(helpText);
395     };
396
397     it('should show the add matcher button', () => {
398       fixtureH.expectElementVisible('#add-matcher', true);
399       fixtureH.expectIdElementsVisible(
400         [
401           'matcher-name-0',
402           'matcher-value-0',
403           'matcher-isRegex-0',
404           'matcher-edit-0',
405           'matcher-delete-0'
406         ],
407         false
408       );
409       expectMatch(null);
410     });
411
412     it('should show added matcher', () => {
413       addMatcher('job', 'someJob', true);
414       fixtureH.expectIdElementsVisible(
415         [
416           'matcher-name-0',
417           'matcher-value-0',
418           'matcher-isRegex-0',
419           'matcher-edit-0',
420           'matcher-delete-0'
421         ],
422         true
423       );
424       expectMatch(null);
425     });
426
427     it('should show multiple matchers', () => {
428       addMatcher('severity', 'someSeverity', false);
429       addMatcher('alertname', 'alert0', false);
430       fixtureH.expectIdElementsVisible(
431         [
432           'matcher-name-0',
433           'matcher-value-0',
434           'matcher-isRegex-0',
435           'matcher-edit-0',
436           'matcher-delete-0',
437           'matcher-name-1',
438           'matcher-value-1',
439           'matcher-isRegex-1',
440           'matcher-edit-1',
441           'matcher-delete-1'
442         ],
443         true
444       );
445       expectMatch('Matches 1 rule with 1 active alert.');
446     });
447
448     it('should show the right matcher values', () => {
449       addMatcher('alertname', 'alert.*', true);
450       addMatcher('job', 'someJob', false);
451       fixture.detectChanges();
452       fixtureH.expectFormFieldToBe('#matcher-name-0', 'alertname');
453       fixtureH.expectFormFieldToBe('#matcher-value-0', 'alert.*');
454       fixtureH.expectFormFieldToBe('#matcher-isRegex-0', 'true');
455       fixtureH.expectFormFieldToBe('#matcher-isRegex-1', 'false');
456       expectMatch(null);
457     });
458
459     it('should be able to edit a matcher', () => {
460       addMatcher('alertname', 'alert.*', true);
461       expectMatch(null);
462
463       const modalService = TestBed.inject(BsModalService);
464       spyOn(modalService, 'show').and.callFake(() => {
465         return {
466           content: {
467             preFillControls: (matcher: any) => {
468               expect(matcher).toBe(component.matchers[0]);
469             },
470             submitAction: of({ name: 'alertname', value: 'alert0', isRegex: false })
471           }
472         };
473       });
474       fixtureH.clickElement('#matcher-edit-0');
475
476       fixtureH.expectFormFieldToBe('#matcher-name-0', 'alertname');
477       fixtureH.expectFormFieldToBe('#matcher-value-0', 'alert0');
478       fixtureH.expectFormFieldToBe('#matcher-isRegex-0', 'false');
479       expectMatch('Matches 1 rule with 1 active alert.');
480     });
481
482     it('should be able to remove a matcher', () => {
483       addMatcher('alertname', 'alert0', false);
484       expectMatch('Matches 1 rule with 1 active alert.');
485       fixtureH.clickElement('#matcher-delete-0');
486       expect(component.matchers).toEqual([]);
487       fixtureH.expectIdElementsVisible(
488         ['matcher-name-0', 'matcher-value-0', 'matcher-isRegex-0'],
489         false
490       );
491       expectMatch(null);
492     });
493
494     it('should be able to remove a matcher and update the matcher text', () => {
495       addMatcher('alertname', 'alert0', false);
496       addMatcher('alertname', 'alert1', false);
497       expectMatch('Your matcher seems to match no currently defined rule or active alert.');
498       fixtureH.clickElement('#matcher-delete-1');
499       expectMatch('Matches 1 rule with 1 active alert.');
500     });
501
502     it('should show form as invalid if no matcher is set', () => {
503       expect(form.errors).toEqual({ matcherRequired: true });
504     });
505
506     it('should show form as valid if matcher was added', () => {
507       addMatcher('some name', 'some value', true);
508       expect(form.errors).toEqual(null);
509     });
510   });
511
512   describe('submit tests', () => {
513     const endsAt = new Date('2022-02-22T02:00:00');
514     let silence: AlertmanagerSilence;
515     const silenceId = '50M3-10N6-1D';
516
517     const expectSuccessNotification = (titleStartsWith: string) =>
518       expect(notificationService.show).toHaveBeenCalledWith(
519         NotificationType.success,
520         `${titleStartsWith} silence ${silenceId}`,
521         undefined,
522         undefined,
523         'Prometheus'
524       );
525
526     const fillAndSubmit = () => {
527       ['createdBy', 'comment'].forEach((attr) => {
528         formHelper.setValue(attr, silence[attr]);
529       });
530       silence.matchers.forEach((matcher) =>
531         addMatcher(matcher.name, matcher.value, matcher.isRegex)
532       );
533       component.submit();
534     };
535
536     beforeEach(() => {
537       spyOn(prometheusService, 'setSilence').and.callFake(() => of({ body: { silenceId } }));
538       spyOn(router, 'navigate').and.stub();
539       silence = {
540         createdBy: 'some creator',
541         comment: 'some comment',
542         startsAt: baseTime.toISOString(),
543         endsAt: endsAt.toISOString(),
544         matchers: [
545           {
546             name: 'some attribute name',
547             value: 'some value',
548             isRegex: false
549           },
550           {
551             name: 'job',
552             value: 'node-exporter',
553             isRegex: false
554           },
555           {
556             name: 'instance',
557             value: 'localhost:9100',
558             isRegex: false
559           },
560           {
561             name: 'alertname',
562             value: 'load_0',
563             isRegex: false
564           }
565         ]
566       };
567     });
568
569     it('should not create a silence if the form is invalid', () => {
570       component.submit();
571       expect(notificationService.show).not.toHaveBeenCalled();
572       expect(form.valid).toBeFalsy();
573       expect(prometheusService.setSilence).not.toHaveBeenCalledWith(silence);
574       expect(router.navigate).not.toHaveBeenCalled();
575     });
576
577     it('should route back to previous tab on success', () => {
578       fillAndSubmit();
579       expect(form.valid).toBeTruthy();
580       expect(router.navigate).toHaveBeenCalledWith(['/monitoring'], { fragment: 'silences' });
581     });
582
583     it('should create a silence', () => {
584       fillAndSubmit();
585       expect(prometheusService.setSilence).toHaveBeenCalledWith(silence);
586       expectSuccessNotification('Created');
587     });
588
589     it('should recreate a silence', () => {
590       component.recreate = true;
591       component.id = 'recreateId';
592       fillAndSubmit();
593       expect(prometheusService.setSilence).toHaveBeenCalledWith(silence);
594       expectSuccessNotification('Recreated');
595     });
596
597     it('should edit a silence', () => {
598       component.edit = true;
599       component.id = 'editId';
600       silence.id = component.id;
601       fillAndSubmit();
602       expect(prometheusService.setSilence).toHaveBeenCalledWith(silence);
603       expectSuccessNotification('Edited');
604     });
605   });
606 });