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