foreman_rh_cloud 13.0.12 → 13.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,567 @@
1
+ /* eslint-disable max-lines */
2
+ // Create mocks
3
+ const mockGetState = jest.fn();
4
+ const mockDispatch = jest.fn();
5
+ const mockPatch = jest.fn();
6
+ const mockGet = jest.fn();
7
+
8
+ // Mock modules
9
+ jest.mock('../hostVulnerabilityStoreUtils');
10
+ jest.mock(
11
+ 'foremanReact/redux',
12
+ () => ({
13
+ __esModule: true,
14
+ default: {
15
+ getState: () => mockGetState(),
16
+ dispatch: action => mockDispatch(action),
17
+ },
18
+ }),
19
+ { virtual: true }
20
+ );
21
+ jest.mock(
22
+ 'foremanReact/redux/API',
23
+ () => ({
24
+ patch: (...args) => mockPatch(...args),
25
+ get: (...args) => mockGet(...args),
26
+ }),
27
+ { virtual: true }
28
+ );
29
+
30
+ // Import after mocking
31
+ // eslint-disable-next-line import/first
32
+ import * as storeUtils from '../hostVulnerabilityStoreUtils';
33
+ // eslint-disable-next-line import/first
34
+ import getVulnerabilityAnalysisActions from '../VulnerabilityAnalysisActions';
35
+
36
+ describe('VulnerabilityAnalysisActions', () => {
37
+ beforeEach(() => {
38
+ mockGetState.mockReturnValue({ API: {} });
39
+ mockDispatch.mockImplementation(action => action);
40
+ jest.clearAllMocks();
41
+ });
42
+
43
+ describe('getVulnerabilityAnalysisActions', () => {
44
+ it('returns empty array when not in IoP mode', () => {
45
+ const hostDetails = {
46
+ id: 1,
47
+ insights_attributes: {
48
+ use_iop_mode: false,
49
+ },
50
+ subscription_facet_attributes: {
51
+ uuid: 'test-uuid',
52
+ },
53
+ };
54
+
55
+ const actions = getVulnerabilityAnalysisActions(hostDetails);
56
+
57
+ expect(actions).toEqual([]);
58
+ });
59
+
60
+ it('returns empty array when host has no subscription UUID', () => {
61
+ const hostDetails = {
62
+ id: 1,
63
+ insights_attributes: {
64
+ use_iop_mode: true,
65
+ },
66
+ subscription_facet_attributes: {
67
+ uuid: null,
68
+ },
69
+ };
70
+
71
+ const actions = getVulnerabilityAnalysisActions(hostDetails);
72
+
73
+ expect(actions).toEqual([]);
74
+ });
75
+
76
+ it('returns empty array when API returned empty data (no system record)', () => {
77
+ mockGetState.mockReturnValue({
78
+ API: {
79
+ 'HOST_CVE_COUNT_test-uuid': {
80
+ response: {
81
+ data: [], // Empty array means no system record exists
82
+ },
83
+ },
84
+ },
85
+ });
86
+
87
+ const hostDetails = {
88
+ id: 1,
89
+ insights_attributes: {
90
+ use_iop_mode: true,
91
+ },
92
+ subscription_facet_attributes: {
93
+ uuid: 'test-uuid',
94
+ },
95
+ };
96
+
97
+ const actions = getVulnerabilityAnalysisActions(hostDetails);
98
+
99
+ expect(actions).toEqual([]);
100
+ });
101
+
102
+ it('returns empty array when opt_out is undefined (data not loaded yet)', () => {
103
+ mockGetState.mockReturnValue({
104
+ API: {
105
+ 'HOST_CVE_COUNT_test-uuid': {
106
+ response: null,
107
+ },
108
+ },
109
+ });
110
+
111
+ const hostDetails = {
112
+ id: 1,
113
+ insights_attributes: {
114
+ use_iop_mode: true,
115
+ },
116
+ subscription_facet_attributes: {
117
+ uuid: 'test-uuid',
118
+ },
119
+ };
120
+
121
+ const actions = getVulnerabilityAnalysisActions(hostDetails);
122
+
123
+ expect(actions).toEqual([]);
124
+ });
125
+
126
+ it('returns "Enable vulnerability analysis" action when opt_out is true', () => {
127
+ const hostDetails = {
128
+ id: 1,
129
+ insights_attributes: {
130
+ use_iop_mode: true,
131
+ vulnerability_opt_out: true,
132
+ },
133
+ subscription_facet_attributes: {
134
+ uuid: 'test-uuid',
135
+ },
136
+ };
137
+
138
+ mockPatch.mockReturnValue({ type: 'MOCK_PATCH_ACTION' });
139
+
140
+ const actions = getVulnerabilityAnalysisActions(hostDetails);
141
+
142
+ expect(actions).toHaveLength(1);
143
+ expect(actions[0].title).toBe('Enable vulnerability analysis');
144
+ expect(actions[0].onClick).toBeDefined();
145
+
146
+ // Trigger onClick to verify it dispatches correctly
147
+ actions[0].onClick();
148
+
149
+ expect(mockDispatch).toHaveBeenCalledWith({
150
+ type: 'MOCK_PATCH_ACTION',
151
+ });
152
+ expect(mockPatch).toHaveBeenCalledWith(
153
+ expect.objectContaining({
154
+ key: 'HOST_CVE_COUNT_test-uuid',
155
+ params: {
156
+ inventory_id: 'test-uuid',
157
+ opt_out: false, // Should toggle to false
158
+ },
159
+ })
160
+ );
161
+ });
162
+
163
+ it('returns "Disable vulnerability analysis" action when opt_out is false', () => {
164
+ const hostDetails = {
165
+ id: 1,
166
+ insights_attributes: {
167
+ use_iop_mode: true,
168
+ vulnerability_opt_out: false,
169
+ },
170
+ subscription_facet_attributes: {
171
+ uuid: 'test-uuid',
172
+ },
173
+ };
174
+
175
+ mockPatch.mockReturnValue({ type: 'MOCK_PATCH_ACTION' });
176
+
177
+ const actions = getVulnerabilityAnalysisActions(hostDetails);
178
+
179
+ expect(actions).toHaveLength(1);
180
+ expect(actions[0].title).toBe('Disable vulnerability analysis');
181
+ expect(actions[0].onClick).toBeDefined();
182
+
183
+ // Trigger onClick to verify it dispatches correctly
184
+ actions[0].onClick();
185
+
186
+ expect(mockDispatch).toHaveBeenCalledWith({
187
+ type: 'MOCK_PATCH_ACTION',
188
+ });
189
+ expect(mockPatch).toHaveBeenCalledWith(
190
+ expect.objectContaining({
191
+ key: 'HOST_CVE_COUNT_test-uuid',
192
+ params: {
193
+ inventory_id: 'test-uuid',
194
+ opt_out: true, // Should toggle to true
195
+ },
196
+ })
197
+ );
198
+ });
199
+
200
+ it('falls back to CVE API cache when opt_out not set on host object', () => {
201
+ mockGetState.mockReturnValue({
202
+ API: {
203
+ 'HOST_CVE_COUNT_test-uuid': {
204
+ response: {
205
+ data: [
206
+ {
207
+ attributes: {
208
+ opt_out: true,
209
+ },
210
+ },
211
+ ],
212
+ },
213
+ },
214
+ },
215
+ });
216
+
217
+ const hostDetails = {
218
+ id: 1,
219
+ insights_attributes: {
220
+ use_iop_mode: true,
221
+ // No vulnerability_opt_out set
222
+ },
223
+ subscription_facet_attributes: {
224
+ uuid: 'test-uuid',
225
+ },
226
+ };
227
+
228
+ mockPatch.mockReturnValue({ type: 'MOCK_PATCH_ACTION' });
229
+
230
+ const actions = getVulnerabilityAnalysisActions(hostDetails);
231
+
232
+ expect(actions).toHaveLength(1);
233
+ expect(actions[0].title).toBe('Enable vulnerability analysis');
234
+ });
235
+ });
236
+
237
+ describe('handleToggle action creator', () => {
238
+ it('creates patch action with correct parameters', () => {
239
+ const mockPatchAction = { type: 'MOCK_PATCH_ACTION' };
240
+ mockPatch.mockReturnValue(mockPatchAction);
241
+
242
+ const hostDetails = {
243
+ id: 123,
244
+ insights_attributes: {
245
+ use_iop_mode: true,
246
+ vulnerability_opt_out: false,
247
+ },
248
+ subscription_facet_attributes: {
249
+ uuid: 'test-uuid-456',
250
+ },
251
+ };
252
+
253
+ const actions = getVulnerabilityAnalysisActions(hostDetails);
254
+ actions[0].onClick();
255
+
256
+ expect(mockPatch).toHaveBeenCalledWith(
257
+ expect.objectContaining({
258
+ key: 'HOST_CVE_COUNT_test-uuid-456',
259
+ url: expect.stringContaining('api/vulnerability/v1/systems/opt_out'),
260
+ params: {
261
+ inventory_id: 'test-uuid-456',
262
+ opt_out: true,
263
+ },
264
+ headers: {
265
+ 'Content-Type': 'application/vnd.api+json',
266
+ },
267
+ })
268
+ );
269
+ });
270
+
271
+ it('includes updateData callback when disabling (optimistic update)', () => {
272
+ mockPatch.mockReturnValue({ type: 'MOCK_PATCH_ACTION' });
273
+
274
+ const hostDetails = {
275
+ id: 1,
276
+ insights_attributes: {
277
+ use_iop_mode: true,
278
+ vulnerability_opt_out: false,
279
+ },
280
+ subscription_facet_attributes: {
281
+ uuid: 'test-uuid',
282
+ },
283
+ };
284
+
285
+ const actions = getVulnerabilityAnalysisActions(hostDetails);
286
+ actions[0].onClick(); // Clicking "Disable" action
287
+
288
+ const patchCall = mockPatch.mock.calls[0][0];
289
+ expect(patchCall.updateData).toBeDefined();
290
+
291
+ // Test updateData callback
292
+ const prevState = {
293
+ data: [
294
+ {
295
+ id: 'system-1',
296
+ attributes: {
297
+ cve_count: 5,
298
+ opt_out: false,
299
+ },
300
+ },
301
+ ],
302
+ meta: { total: 1 },
303
+ };
304
+
305
+ const newState = patchCall.updateData(prevState);
306
+
307
+ expect(newState).toEqual({
308
+ data: [
309
+ {
310
+ id: 'system-1',
311
+ attributes: {
312
+ cve_count: 5,
313
+ opt_out: true, // Should be updated to true
314
+ },
315
+ },
316
+ ],
317
+ meta: { total: 1 },
318
+ });
319
+ });
320
+
321
+ it('skips updateData when enabling (will refetch instead)', () => {
322
+ mockPatch.mockReturnValue({ type: 'MOCK_PATCH_ACTION' });
323
+
324
+ const hostDetails = {
325
+ id: 1,
326
+ insights_attributes: {
327
+ use_iop_mode: true,
328
+ vulnerability_opt_out: true, // Currently disabled
329
+ },
330
+ subscription_facet_attributes: {
331
+ uuid: 'test-uuid',
332
+ },
333
+ };
334
+
335
+ const actions = getVulnerabilityAnalysisActions(hostDetails);
336
+ actions[0].onClick(); // Clicking "Enable" action
337
+
338
+ const patchCall = mockPatch.mock.calls[0][0];
339
+ // When enabling, updateData should be undefined (no optimistic update)
340
+ expect(patchCall.updateData).toBeUndefined();
341
+ });
342
+
343
+ it('updateData handles missing data gracefully when disabling', () => {
344
+ mockPatch.mockReturnValue({ type: 'MOCK_PATCH_ACTION' });
345
+
346
+ const hostDetails = {
347
+ id: 1,
348
+ insights_attributes: {
349
+ use_iop_mode: true,
350
+ vulnerability_opt_out: false,
351
+ },
352
+ subscription_facet_attributes: {
353
+ uuid: 'test-uuid',
354
+ },
355
+ };
356
+
357
+ const actions = getVulnerabilityAnalysisActions(hostDetails);
358
+ actions[0].onClick();
359
+
360
+ const patchCall = mockPatch.mock.calls[0][0];
361
+
362
+ // Test with undefined prevState
363
+ expect(patchCall.updateData(undefined)).toBeUndefined();
364
+
365
+ // Test with missing data
366
+ expect(patchCall.updateData({})).toEqual({});
367
+
368
+ // Test with null data
369
+ expect(patchCall.updateData({ data: null })).toEqual({ data: null });
370
+ });
371
+
372
+ it('includes successToast callback that returns correct message for disabling', () => {
373
+ mockPatch.mockReturnValue({ type: 'MOCK_PATCH_ACTION' });
374
+
375
+ const hostDetails = {
376
+ id: 1,
377
+ insights_attributes: {
378
+ use_iop_mode: true,
379
+ vulnerability_opt_out: false,
380
+ },
381
+ subscription_facet_attributes: {
382
+ uuid: 'test-uuid',
383
+ },
384
+ };
385
+
386
+ const actions = getVulnerabilityAnalysisActions(hostDetails);
387
+ actions[0].onClick();
388
+
389
+ const patchCall = mockPatch.mock.calls[0][0];
390
+ expect(patchCall.successToast).toBeDefined();
391
+
392
+ const message = patchCall.successToast();
393
+ expect(message).toBe('Vulnerability analysis disabled');
394
+ });
395
+
396
+ it('includes successToast callback that returns correct message for enabling', () => {
397
+ mockPatch.mockReturnValue({ type: 'MOCK_PATCH_ACTION' });
398
+
399
+ const hostDetails = {
400
+ id: 1,
401
+ insights_attributes: {
402
+ use_iop_mode: true,
403
+ vulnerability_opt_out: true,
404
+ },
405
+ subscription_facet_attributes: {
406
+ uuid: 'test-uuid',
407
+ },
408
+ };
409
+
410
+ const actions = getVulnerabilityAnalysisActions(hostDetails);
411
+ actions[0].onClick();
412
+
413
+ const patchCall = mockPatch.mock.calls[0][0];
414
+ const message = patchCall.successToast();
415
+ expect(message).toBe('Vulnerability analysis enabled');
416
+ });
417
+
418
+ it('includes handleSuccess callback that updates HOSTS Redux data when disabling', () => {
419
+ mockPatch.mockReturnValue({ type: 'MOCK_PATCH_ACTION' });
420
+ storeUtils.updateHostVulnerabilityOptOut.mockImplementation(() => {});
421
+
422
+ const hostDetails = {
423
+ id: 123,
424
+ insights_attributes: {
425
+ use_iop_mode: true,
426
+ vulnerability_opt_out: false,
427
+ },
428
+ subscription_facet_attributes: {
429
+ uuid: 'test-uuid',
430
+ },
431
+ };
432
+
433
+ const actions = getVulnerabilityAnalysisActions(hostDetails);
434
+
435
+ // Clear dispatch mock before calling onClick (which dispatches the patch action)
436
+ mockDispatch.mockClear();
437
+ mockGet.mockClear();
438
+
439
+ actions[0].onClick();
440
+
441
+ const patchCall = mockPatch.mock.calls[0][0];
442
+ expect(patchCall.handleSuccess).toBeDefined();
443
+
444
+ // Call handleSuccess
445
+ patchCall.handleSuccess();
446
+
447
+ expect(storeUtils.updateHostVulnerabilityOptOut).toHaveBeenCalledWith(
448
+ 123,
449
+ true
450
+ );
451
+ // When disabling, should NOT dispatch GET (no refetch needed)
452
+ expect(mockGet).not.toHaveBeenCalled();
453
+ });
454
+
455
+ it('includes handleSuccess callback that refetches data when enabling', () => {
456
+ mockPatch.mockReturnValue({ type: 'MOCK_PATCH_ACTION' });
457
+ mockGet.mockReturnValue({ type: 'MOCK_GET_ACTION' });
458
+ storeUtils.updateHostVulnerabilityOptOut.mockImplementation(() => {});
459
+
460
+ const hostDetails = {
461
+ id: 123,
462
+ insights_attributes: {
463
+ use_iop_mode: true,
464
+ vulnerability_opt_out: true, // Currently disabled
465
+ },
466
+ subscription_facet_attributes: {
467
+ uuid: 'test-uuid-456',
468
+ },
469
+ };
470
+
471
+ const actions = getVulnerabilityAnalysisActions(hostDetails);
472
+ actions[0].onClick(); // Click "Enable"
473
+
474
+ const patchCall = mockPatch.mock.calls[0][0];
475
+ expect(patchCall.handleSuccess).toBeDefined();
476
+
477
+ // Call handleSuccess
478
+ patchCall.handleSuccess();
479
+
480
+ // Should update HOSTS Redux data
481
+ expect(storeUtils.updateHostVulnerabilityOptOut).toHaveBeenCalledWith(
482
+ 123,
483
+ false // Enabling sets to false
484
+ );
485
+
486
+ // Should dispatch GET to refetch CVE data
487
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'MOCK_GET_ACTION' });
488
+ expect(mockGet).toHaveBeenCalledWith(
489
+ expect.objectContaining({
490
+ key: 'HOST_CVE_COUNT_test-uuid-456',
491
+ url: expect.stringContaining(
492
+ 'api/vulnerability/v1/systems?uuid=test-uuid-456'
493
+ ),
494
+ })
495
+ );
496
+ });
497
+
498
+ it('includes errorToast callback that extracts JSON:API error detail', () => {
499
+ mockPatch.mockReturnValue({ type: 'MOCK_PATCH_ACTION' });
500
+
501
+ const hostDetails = {
502
+ id: 1,
503
+ insights_attributes: {
504
+ use_iop_mode: true,
505
+ vulnerability_opt_out: false,
506
+ },
507
+ subscription_facet_attributes: {
508
+ uuid: 'test-uuid',
509
+ },
510
+ };
511
+
512
+ const actions = getVulnerabilityAnalysisActions(hostDetails);
513
+ actions[0].onClick();
514
+
515
+ const patchCall = mockPatch.mock.calls[0][0];
516
+ expect(patchCall.errorToast).toBeDefined();
517
+
518
+ // Test with JSON:API error format
519
+ const errorWithDetail = {
520
+ response: {
521
+ data: {
522
+ errors: [
523
+ {
524
+ detail: 'System not found in vulnerability service',
525
+ },
526
+ ],
527
+ },
528
+ },
529
+ };
530
+
531
+ const errorMessage = patchCall.errorToast(errorWithDetail);
532
+ expect(errorMessage).toBe('System not found in vulnerability service');
533
+ });
534
+
535
+ it('includes errorToast callback that falls back to generic message', () => {
536
+ mockPatch.mockReturnValue({ type: 'MOCK_PATCH_ACTION' });
537
+
538
+ const hostDetails = {
539
+ id: 1,
540
+ insights_attributes: {
541
+ use_iop_mode: true,
542
+ vulnerability_opt_out: false,
543
+ },
544
+ subscription_facet_attributes: {
545
+ uuid: 'test-uuid',
546
+ },
547
+ };
548
+
549
+ const actions = getVulnerabilityAnalysisActions(hostDetails);
550
+ actions[0].onClick();
551
+
552
+ const patchCall = mockPatch.mock.calls[0][0];
553
+
554
+ // Test with error that doesn't have JSON:API format
555
+ const genericError = {
556
+ response: {
557
+ data: {},
558
+ },
559
+ };
560
+
561
+ const errorMessage = patchCall.errorToast(genericError);
562
+ expect(errorMessage).toBe(
563
+ 'Failed to update vulnerability analysis status'
564
+ );
565
+ });
566
+ });
567
+ });
@@ -0,0 +1,41 @@
1
+ import store from 'foremanReact/redux';
2
+
3
+ /**
4
+ * Updates a host's vulnerability_opt_out status in the HOSTS Redux cache
5
+ * @param {number} hostId - The host ID to update
6
+ * @param {boolean} optOutValue - The new opt_out status
7
+ * @param {function} dispatch - Redux dispatch function (optional, uses store.dispatch if not provided)
8
+ */
9
+ export const updateHostVulnerabilityOptOut = (
10
+ hostId,
11
+ optOutValue,
12
+ dispatch = null
13
+ ) => {
14
+ const dispatchFn = dispatch || store.dispatch;
15
+ const state = store.getState();
16
+ const hostsData = state.API?.HOSTS;
17
+
18
+ if (!hostsData?.response?.results || !hostId) {
19
+ return;
20
+ }
21
+
22
+ dispatchFn({
23
+ type: 'HOSTS_SUCCESS',
24
+ key: 'HOSTS',
25
+ response: {
26
+ ...hostsData.response,
27
+ results: hostsData.response.results.map(host => {
28
+ if (host.id === hostId) {
29
+ return {
30
+ ...host,
31
+ insights_attributes: {
32
+ ...host.insights_attributes,
33
+ vulnerability_opt_out: optOutValue,
34
+ },
35
+ };
36
+ }
37
+ return host;
38
+ }),
39
+ },
40
+ });
41
+ };
@@ -0,0 +1,14 @@
1
+ .disabled-menu-item-span {
2
+ width: 25em;
3
+ display: flex;
4
+ flex-direction: row;
5
+ }
6
+
7
+ .disabled-menu-item-p {
8
+ margin-left: 0.6em;
9
+ word-break: normal;
10
+ }
11
+
12
+ .disabled-menu-item-icon {
13
+ font-size: small;
14
+ }