]> git.proxmox.com Git - ceph.git/blob - ceph/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.spec.ts
41898315019c9e8dafe960dc0e2ebb15a4f5f6b6
[ceph.git] / ceph / src / pybind / mgr / dashboard / frontend / src / app / ceph / cluster / prometheus / silence-form / silence-form.component.spec.ts
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 [
408 'matcher-name-0',
409 'matcher-value-0',
410 'matcher-isRegex-0',
411 'matcher-edit-0',
412 'matcher-delete-0'
413 ],
414 true
415 );
416 expectMatch(null);
417 });
418
419 it('should show multiple matchers', () => {
420 addMatcher('severity', 'someSeverity', false);
421 addMatcher('alertname', 'alert0', false);
422 fixtureH.expectIdElementsVisible(
423 [
424 'matcher-name-0',
425 'matcher-value-0',
426 'matcher-isRegex-0',
427 'matcher-edit-0',
428 'matcher-delete-0',
429 'matcher-name-1',
430 'matcher-value-1',
431 'matcher-isRegex-1',
432 'matcher-edit-1',
433 'matcher-delete-1'
434 ],
435 true
436 );
437 expectMatch('Matches 1 rule with 1 active alert.');
438 });
439
440 it('should show the right matcher values', () => {
441 addMatcher('alertname', 'alert.*', true);
442 addMatcher('job', 'someJob', false);
443 fixture.detectChanges();
444 fixtureH.expectFormFieldToBe('#matcher-name-0', 'alertname');
445 fixtureH.expectFormFieldToBe('#matcher-value-0', 'alert.*');
446 fixtureH.expectFormFieldToBe('#matcher-isRegex-0', 'true');
447 fixtureH.expectFormFieldToBe('#matcher-isRegex-1', 'false');
448 expectMatch(null);
449 });
450
451 it('should be able to edit a matcher', () => {
452 addMatcher('alertname', 'alert.*', true);
453 expectMatch(null);
454
455 const modalService = TestBed.inject(ModalService);
456 spyOn(modalService, 'show').and.callFake(() => {
457 return {
458 componentInstance: {
459 preFillControls: (matcher: any) => {
460 expect(matcher).toBe(component.matchers[0]);
461 },
462 submitAction: of({ name: 'alertname', value: 'alert0', isRegex: false })
463 }
464 };
465 });
466 fixtureH.clickElement('#matcher-edit-0');
467
468 fixtureH.expectFormFieldToBe('#matcher-name-0', 'alertname');
469 fixtureH.expectFormFieldToBe('#matcher-value-0', 'alert0');
470 fixtureH.expectFormFieldToBe('#matcher-isRegex-0', 'false');
471 expectMatch('Matches 1 rule with 1 active alert.');
472 });
473
474 it('should be able to remove a matcher', () => {
475 addMatcher('alertname', 'alert0', false);
476 expectMatch('Matches 1 rule with 1 active alert.');
477 fixtureH.clickElement('#matcher-delete-0');
478 expect(component.matchers).toEqual([]);
479 fixtureH.expectIdElementsVisible(
480 ['matcher-name-0', 'matcher-value-0', 'matcher-isRegex-0'],
481 false
482 );
483 expectMatch(null);
484 });
485
486 it('should be able to remove a matcher and update the matcher text', () => {
487 addMatcher('alertname', 'alert0', false);
488 addMatcher('alertname', 'alert1', false);
489 expectMatch('Your matcher seems to match no currently defined rule or active alert.');
490 fixtureH.clickElement('#matcher-delete-1');
491 expectMatch('Matches 1 rule with 1 active alert.');
492 });
493
494 it('should show form as invalid if no matcher is set', () => {
495 expect(form.errors).toEqual({ matcherRequired: true });
496 });
497
498 it('should show form as valid if matcher was added', () => {
499 addMatcher('some name', 'some value', true);
500 expect(form.errors).toEqual(null);
501 });
502 });
503
504 describe('submit tests', () => {
505 const endsAt = '2022-02-22 02:00';
506 let silence: AlertmanagerSilence;
507 const silenceId = '50M3-10N6-1D';
508
509 const expectSuccessNotification = (titleStartsWith: string) =>
510 expect(notificationService.show).toHaveBeenCalledWith(
511 NotificationType.success,
512 `${titleStartsWith} silence ${silenceId}`,
513 undefined,
514 undefined,
515 'Prometheus'
516 );
517
518 const fillAndSubmit = () => {
519 ['createdBy', 'comment'].forEach((attr) => {
520 formHelper.setValue(attr, silence[attr]);
521 });
522 silence.matchers.forEach((matcher) =>
523 addMatcher(matcher.name, matcher.value, matcher.isRegex)
524 );
525 component.submit();
526 };
527
528 beforeEach(() => {
529 spyOn(prometheusService, 'setSilence').and.callFake(() => of({ body: { silenceId } }));
530 spyOn(router, 'navigate').and.stub();
531 silence = {
532 createdBy: 'some creator',
533 comment: 'some comment',
534 startsAt: moment(baseTime).toISOString(),
535 endsAt: moment(endsAt).toISOString(),
536 matchers: [
537 {
538 name: 'some attribute name',
539 value: 'some value',
540 isRegex: false
541 },
542 {
543 name: 'job',
544 value: 'node-exporter',
545 isRegex: false
546 },
547 {
548 name: 'instance',
549 value: 'localhost:9100',
550 isRegex: false
551 },
552 {
553 name: 'alertname',
554 value: 'load_0',
555 isRegex: false
556 }
557 ]
558 };
559 });
560
561 // it('should not create a silence if the form is invalid', () => {
562 // component.submit();
563 // expect(notificationService.show).not.toHaveBeenCalled();
564 // expect(form.valid).toBeFalsy();
565 // expect(prometheusService.setSilence).not.toHaveBeenCalledWith(silence);
566 // expect(router.navigate).not.toHaveBeenCalled();
567 // });
568
569 // it('should route back to previous tab on success', () => {
570 // fillAndSubmit();
571 // expect(form.valid).toBeTruthy();
572 // expect(router.navigate).toHaveBeenCalledWith(['/monitoring'], { fragment: 'silences' });
573 // });
574
575 it('should create a silence', () => {
576 fillAndSubmit();
577 expect(prometheusService.setSilence).toHaveBeenCalledWith(silence);
578 expectSuccessNotification('Created');
579 });
580
581 it('should recreate a silence', () => {
582 component.recreate = true;
583 component.id = 'recreateId';
584 fillAndSubmit();
585 expect(prometheusService.setSilence).toHaveBeenCalledWith(silence);
586 expectSuccessNotification('Recreated');
587 });
588
589 it('should edit a silence', () => {
590 component.edit = true;
591 component.id = 'editId';
592 silence.id = component.id;
593 fillAndSubmit();
594 expect(prometheusService.setSilence).toHaveBeenCalledWith(silence);
595 expectSuccessNotification('Edited');
596 });
597 });
598 });