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