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