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.
- checksums.yaml +4 -4
- data/lib/foreman_rh_cloud/version.rb +1 -1
- data/package.json +1 -1
- data/webpack/ForemanColumnExtensions/index.js +5 -3
- data/webpack/ForemanRhCloudFills.js +7 -0
- data/webpack/HostsIndexExtensions/VulnerabilityAnalysisActions.js +138 -0
- data/webpack/HostsIndexExtensions/__tests__/VulnerabilityAnalysisActions.test.js +567 -0
- data/webpack/HostsIndexExtensions/hostVulnerabilityStoreUtils.js +41 -0
- data/webpack/InsightsVulnerabilityActionsBar/InsightsVulnerabilityActionsBar.scss +14 -0
- data/webpack/InsightsVulnerabilityActionsBar/InsightsVulnerabilityActionsBarActions.js +169 -0
- data/webpack/InsightsVulnerabilityActionsBar/__tests__/InsightsVulnerabilityActionsBar.test.js +91 -0
- data/webpack/InsightsVulnerabilityActionsBar/__tests__/InsightsVulnerabilityActionsBarActions.test.js +299 -0
- data/webpack/InsightsVulnerabilityActionsBar/index.js +93 -0
- data/webpack/InsightsVulnerabilityHostIndexExtensions/CVECountCell.js +25 -13
- data/webpack/InsightsVulnerabilityHostIndexExtensions/__tests__/CVECountCell.test.js +156 -87
- data/webpack/__mocks__/foremanReact/Root/Context/ForemanContext.js +6 -2
- data/webpack/__mocks__/foremanReact/common/I18n.js +2 -1
- data/webpack/__mocks__/foremanReact/common/helpers.js +11 -0
- data/webpack/__mocks__/foremanReact/components/HostsIndex/index.js +8 -0
- data/webpack/__mocks__/foremanReact/redux.js +10 -0
- data/webpack/common/Hooks/ConfigHooks.js +1 -1
- data/webpack/global_index.js +7 -0
- metadata +11 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { translate as __, sprintf, ngettext } from 'foremanReact/common/I18n';
|
|
2
|
+
import { addToast } from 'foremanReact/components/ToastsList';
|
|
3
|
+
import store from 'foremanReact/redux';
|
|
4
|
+
import { insightsCloudUrl } from '../InsightsCloudSync/InsightsCloudSyncHelpers';
|
|
5
|
+
import { foremanUrl } from '../ForemanRhCloudHelpers';
|
|
6
|
+
|
|
7
|
+
const vulnerabilityApiPath = path =>
|
|
8
|
+
insightsCloudUrl(`api/vulnerability/v1/${path}`);
|
|
9
|
+
|
|
10
|
+
const getCSRFToken = () =>
|
|
11
|
+
document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
12
|
+
|
|
13
|
+
const fetchHostUuids = async searchQuery => {
|
|
14
|
+
const response = await fetch(
|
|
15
|
+
foremanUrl(`/api/hosts?search=${encodeURIComponent(searchQuery)}`),
|
|
16
|
+
{
|
|
17
|
+
headers: {
|
|
18
|
+
Accept: 'application/json',
|
|
19
|
+
'Content-Type': 'application/json',
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
if (!response.ok) {
|
|
25
|
+
throw new Error(__('Failed to fetch hosts'));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const data = await response.json();
|
|
29
|
+
const totalHosts = data.results.length;
|
|
30
|
+
const uuids = data.results
|
|
31
|
+
.map(host => host.subscription_facet_attributes?.uuid)
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
|
|
34
|
+
return { uuids, totalHosts, hostsWithoutUuid: totalHosts - uuids.length };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const updateCveCountCache = (uuids, optOutValue) => {
|
|
38
|
+
const state = store.getState();
|
|
39
|
+
|
|
40
|
+
uuids.forEach(uuid => {
|
|
41
|
+
const apiKey = `HOST_CVE_COUNT_${uuid}`;
|
|
42
|
+
const cveData = state.API?.[apiKey];
|
|
43
|
+
|
|
44
|
+
if (cveData?.response?.data) {
|
|
45
|
+
store.dispatch({
|
|
46
|
+
type: `${apiKey}_SUCCESS`,
|
|
47
|
+
key: apiKey,
|
|
48
|
+
response: {
|
|
49
|
+
...cveData.response,
|
|
50
|
+
data: cveData.response.data.map(system => ({
|
|
51
|
+
...system,
|
|
52
|
+
attributes: {
|
|
53
|
+
...system.attributes,
|
|
54
|
+
opt_out: optOutValue,
|
|
55
|
+
},
|
|
56
|
+
})),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const callOptOutApi = async (uuids, optOutValue) => {
|
|
64
|
+
const csrfToken = getCSRFToken();
|
|
65
|
+
|
|
66
|
+
const response = await fetch(vulnerabilityApiPath('systems/opt_out'), {
|
|
67
|
+
method: 'PATCH',
|
|
68
|
+
headers: {
|
|
69
|
+
'Content-Type': 'application/vnd.api+json',
|
|
70
|
+
'X-CSRF-Token': csrfToken,
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
inventory_id: uuids,
|
|
74
|
+
opt_out: optOutValue,
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
let errorMessage = __('Failed to update vulnerability analysis status');
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const errorBody = await response.json();
|
|
83
|
+
if (
|
|
84
|
+
errorBody?.errors?.[0]?.detail &&
|
|
85
|
+
typeof errorBody.errors[0].detail === 'string' &&
|
|
86
|
+
errorBody.errors[0].detail.trim() !== ''
|
|
87
|
+
) {
|
|
88
|
+
errorMessage = errorBody.errors[0].detail;
|
|
89
|
+
}
|
|
90
|
+
} catch (e) {
|
|
91
|
+
// JSON parse failed, use generic message
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new Error(errorMessage);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return uuids.length;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const handleBulkOptOut = async (fetchBulkParams, optOutValue) => {
|
|
101
|
+
try {
|
|
102
|
+
const searchQuery = fetchBulkParams();
|
|
103
|
+
const { uuids, hostsWithoutUuid } = await fetchHostUuids(searchQuery);
|
|
104
|
+
|
|
105
|
+
if (uuids.length === 0) {
|
|
106
|
+
store.dispatch(
|
|
107
|
+
addToast({
|
|
108
|
+
type: 'warning',
|
|
109
|
+
message: __('None of the selected hosts are registered'),
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const count = await callOptOutApi(uuids, optOutValue);
|
|
116
|
+
|
|
117
|
+
updateCveCountCache(uuids, optOutValue);
|
|
118
|
+
|
|
119
|
+
const message = optOutValue
|
|
120
|
+
? sprintf(
|
|
121
|
+
ngettext(
|
|
122
|
+
'Vulnerability analysis disabled for %s host',
|
|
123
|
+
'Vulnerability analysis disabled for %s hosts',
|
|
124
|
+
count
|
|
125
|
+
),
|
|
126
|
+
count
|
|
127
|
+
)
|
|
128
|
+
: sprintf(
|
|
129
|
+
ngettext(
|
|
130
|
+
'Vulnerability analysis enabled for %s host',
|
|
131
|
+
'Vulnerability analysis enabled for %s hosts',
|
|
132
|
+
count
|
|
133
|
+
),
|
|
134
|
+
count
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
store.dispatch(
|
|
138
|
+
addToast({
|
|
139
|
+
type: 'success',
|
|
140
|
+
message,
|
|
141
|
+
})
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (hostsWithoutUuid > 0) {
|
|
145
|
+
store.dispatch(
|
|
146
|
+
addToast({
|
|
147
|
+
type: 'warning',
|
|
148
|
+
message: sprintf(
|
|
149
|
+
ngettext(
|
|
150
|
+
'%s host was skipped because it is not registered',
|
|
151
|
+
'%s hosts were skipped because they are not registered',
|
|
152
|
+
hostsWithoutUuid
|
|
153
|
+
),
|
|
154
|
+
hostsWithoutUuid
|
|
155
|
+
),
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const errorMessage =
|
|
161
|
+
error.message || __('Failed to update vulnerability analysis status');
|
|
162
|
+
store.dispatch(
|
|
163
|
+
addToast({
|
|
164
|
+
type: 'error',
|
|
165
|
+
message: errorMessage,
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
};
|
data/webpack/InsightsVulnerabilityActionsBar/__tests__/InsightsVulnerabilityActionsBar.test.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import '@testing-library/jest-dom';
|
|
4
|
+
import * as ForemanContext from 'foremanReact/Root/Context/ForemanContext';
|
|
5
|
+
import { ForemanHostsIndexActionsBarContext } from 'foremanReact/components/HostsIndex';
|
|
6
|
+
import * as ConfigHooks from '../../common/Hooks/ConfigHooks';
|
|
7
|
+
import InsightsVulnerabilityActionsBar from '../index';
|
|
8
|
+
|
|
9
|
+
jest.mock('../../common/Hooks/ConfigHooks');
|
|
10
|
+
jest.mock('foremanReact/Root/Context/ForemanContext');
|
|
11
|
+
jest.mock('../InsightsVulnerabilityActionsBarActions');
|
|
12
|
+
|
|
13
|
+
const mockContextValue = {
|
|
14
|
+
fetchBulkParams: jest.fn(() => 'id ^ (1,2,3)'),
|
|
15
|
+
selectedCount: 3,
|
|
16
|
+
setMenuOpen: jest.fn(),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const renderComponent = (contextOverrides = {}, orgId = 1) => {
|
|
20
|
+
const contextValue = { ...mockContextValue, ...contextOverrides };
|
|
21
|
+
|
|
22
|
+
ForemanContext.useForemanOrganization.mockReturnValue({ id: orgId });
|
|
23
|
+
|
|
24
|
+
return render(
|
|
25
|
+
<ForemanHostsIndexActionsBarContext.Provider value={contextValue}>
|
|
26
|
+
<InsightsVulnerabilityActionsBar />
|
|
27
|
+
</ForemanHostsIndexActionsBarContext.Provider>
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe('InsightsVulnerabilityActionsBar', () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
jest.clearAllMocks();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('when IoP is disabled', () => {
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
ConfigHooks.useIopConfig.mockReturnValue(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('renders nothing', () => {
|
|
42
|
+
const { container } = renderComponent();
|
|
43
|
+
expect(container.firstChild).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('when IoP config returns false (converted system)', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
ConfigHooks.useIopConfig.mockReturnValue(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('renders nothing', () => {
|
|
53
|
+
const { container } = renderComponent();
|
|
54
|
+
expect(container.firstChild).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('when IoP is enabled', () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
ConfigHooks.useIopConfig.mockReturnValue(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('renders the menu item', () => {
|
|
64
|
+
renderComponent();
|
|
65
|
+
expect(
|
|
66
|
+
screen.getByText('Manage vulnerability analysis')
|
|
67
|
+
).toBeInTheDocument();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('disables menu when no hosts are selected', () => {
|
|
71
|
+
renderComponent({ selectedCount: 0 });
|
|
72
|
+
const menuItem = screen.getByText('Manage vulnerability analysis');
|
|
73
|
+
expect(menuItem.closest('button')).toBeDisabled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('enables menu when hosts are selected', () => {
|
|
77
|
+
renderComponent({ selectedCount: 3 });
|
|
78
|
+
const menuItem = screen.getByText('Manage vulnerability analysis');
|
|
79
|
+
expect(menuItem.closest('button')).not.toBeDisabled();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('renders menu item with flyout indicator', () => {
|
|
83
|
+
renderComponent();
|
|
84
|
+
const menuItem = screen.getByText('Manage vulnerability analysis');
|
|
85
|
+
const button = menuItem.closest('button');
|
|
86
|
+
|
|
87
|
+
// Button should have aria-haspopup indicating it has a flyout menu
|
|
88
|
+
expect(button).toHaveAttribute('aria-haspopup', 'menu');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import store from 'foremanReact/redux';
|
|
2
|
+
import { handleBulkOptOut } from '../InsightsVulnerabilityActionsBarActions';
|
|
3
|
+
|
|
4
|
+
jest.mock('../../InsightsCloudSync/InsightsCloudSyncHelpers', () => ({
|
|
5
|
+
insightsCloudUrl: jest.fn(path => `/foreman_rh_cloud/${path}`),
|
|
6
|
+
}));
|
|
7
|
+
jest.mock('../../ForemanRhCloudHelpers', () => ({
|
|
8
|
+
foremanUrl: jest.fn(path => path),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
global.fetch = jest.fn();
|
|
12
|
+
Object.defineProperty(document, 'querySelector', {
|
|
13
|
+
value: jest.fn(() => ({ content: 'test-csrf-token' })),
|
|
14
|
+
writable: true,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const hostWithUuid = id => ({
|
|
18
|
+
id,
|
|
19
|
+
subscription_facet_attributes: { uuid: `uuid${id}` },
|
|
20
|
+
});
|
|
21
|
+
const successResponse = () => ({ ok: true, json: async () => ({}) });
|
|
22
|
+
|
|
23
|
+
describe('InsightsVulnerabilityActionsBarActions', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
jest.clearAllMocks();
|
|
26
|
+
store.getState.mockReturnValue({ API: {} });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('handleBulkOptOut', () => {
|
|
30
|
+
const mockFetchBulkParams = jest.fn(() => 'id ^ (1,2,3)');
|
|
31
|
+
|
|
32
|
+
it('shows warning toast when no hosts are registered', async () => {
|
|
33
|
+
global.fetch.mockResolvedValueOnce({
|
|
34
|
+
ok: true,
|
|
35
|
+
json: async () => ({
|
|
36
|
+
results: [
|
|
37
|
+
{ id: 1, name: 'host1' },
|
|
38
|
+
{ id: 2, name: 'host2' },
|
|
39
|
+
],
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await handleBulkOptOut(mockFetchBulkParams, true);
|
|
44
|
+
|
|
45
|
+
expect(store.dispatch).toHaveBeenCalledWith(
|
|
46
|
+
expect.objectContaining({
|
|
47
|
+
type: 'TOASTS_ADD',
|
|
48
|
+
payload: expect.objectContaining({
|
|
49
|
+
message: expect.objectContaining({
|
|
50
|
+
type: 'warning',
|
|
51
|
+
message: 'None of the selected hosts are registered',
|
|
52
|
+
}),
|
|
53
|
+
}),
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('calls opt_out API with correct parameters for disable', async () => {
|
|
59
|
+
global.fetch
|
|
60
|
+
.mockResolvedValueOnce({
|
|
61
|
+
ok: true,
|
|
62
|
+
json: async () => ({ results: [hostWithUuid(1), hostWithUuid(2)] }),
|
|
63
|
+
})
|
|
64
|
+
.mockResolvedValueOnce(successResponse());
|
|
65
|
+
|
|
66
|
+
await handleBulkOptOut(mockFetchBulkParams, true);
|
|
67
|
+
|
|
68
|
+
expect(global.fetch).toHaveBeenCalledTimes(2);
|
|
69
|
+
expect(global.fetch).toHaveBeenLastCalledWith(
|
|
70
|
+
'/foreman_rh_cloud/api/vulnerability/v1/systems/opt_out',
|
|
71
|
+
expect.objectContaining({
|
|
72
|
+
method: 'PATCH',
|
|
73
|
+
headers: expect.objectContaining({
|
|
74
|
+
'Content-Type': 'application/vnd.api+json',
|
|
75
|
+
'X-CSRF-Token': 'test-csrf-token',
|
|
76
|
+
}),
|
|
77
|
+
body: JSON.stringify({
|
|
78
|
+
inventory_id: ['uuid1', 'uuid2'],
|
|
79
|
+
opt_out: true,
|
|
80
|
+
}),
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('calls opt_out API with correct parameters for enable', async () => {
|
|
86
|
+
global.fetch
|
|
87
|
+
.mockResolvedValueOnce({
|
|
88
|
+
ok: true,
|
|
89
|
+
json: async () => ({ results: [hostWithUuid(1)] }),
|
|
90
|
+
})
|
|
91
|
+
.mockResolvedValueOnce(successResponse());
|
|
92
|
+
|
|
93
|
+
await handleBulkOptOut(mockFetchBulkParams, false);
|
|
94
|
+
|
|
95
|
+
expect(global.fetch).toHaveBeenLastCalledWith(
|
|
96
|
+
'/foreman_rh_cloud/api/vulnerability/v1/systems/opt_out',
|
|
97
|
+
expect.objectContaining({
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
inventory_id: ['uuid1'],
|
|
100
|
+
opt_out: false,
|
|
101
|
+
}),
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('shows success toast with singular message for 1 host', async () => {
|
|
107
|
+
global.fetch
|
|
108
|
+
.mockResolvedValueOnce({
|
|
109
|
+
ok: true,
|
|
110
|
+
json: async () => ({ results: [hostWithUuid(1)] }),
|
|
111
|
+
})
|
|
112
|
+
.mockResolvedValueOnce(successResponse());
|
|
113
|
+
|
|
114
|
+
await handleBulkOptOut(mockFetchBulkParams, true);
|
|
115
|
+
|
|
116
|
+
expect(store.dispatch).toHaveBeenCalledWith(
|
|
117
|
+
expect.objectContaining({
|
|
118
|
+
type: 'TOASTS_ADD',
|
|
119
|
+
payload: expect.objectContaining({
|
|
120
|
+
message: expect.objectContaining({
|
|
121
|
+
type: 'success',
|
|
122
|
+
message: 'Vulnerability analysis disabled for 1 host',
|
|
123
|
+
}),
|
|
124
|
+
}),
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('shows success toast with plural message for multiple hosts', async () => {
|
|
130
|
+
global.fetch
|
|
131
|
+
.mockResolvedValueOnce({
|
|
132
|
+
ok: true,
|
|
133
|
+
json: async () => ({ results: [hostWithUuid(1), hostWithUuid(2)] }),
|
|
134
|
+
})
|
|
135
|
+
.mockResolvedValueOnce(successResponse());
|
|
136
|
+
|
|
137
|
+
await handleBulkOptOut(mockFetchBulkParams, false);
|
|
138
|
+
|
|
139
|
+
expect(store.dispatch).toHaveBeenCalledWith(
|
|
140
|
+
expect.objectContaining({
|
|
141
|
+
type: 'TOASTS_ADD',
|
|
142
|
+
payload: expect.objectContaining({
|
|
143
|
+
message: expect.objectContaining({
|
|
144
|
+
type: 'success',
|
|
145
|
+
message: 'Vulnerability analysis enabled for 2 hosts',
|
|
146
|
+
}),
|
|
147
|
+
}),
|
|
148
|
+
})
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('shows warning toast for unregistered hosts', async () => {
|
|
153
|
+
global.fetch
|
|
154
|
+
.mockResolvedValueOnce({
|
|
155
|
+
ok: true,
|
|
156
|
+
json: async () => ({
|
|
157
|
+
results: [
|
|
158
|
+
hostWithUuid(1),
|
|
159
|
+
{ id: 2, name: 'host-without-uuid' },
|
|
160
|
+
{ id: 3, subscription_facet_attributes: {} },
|
|
161
|
+
],
|
|
162
|
+
}),
|
|
163
|
+
})
|
|
164
|
+
.mockResolvedValueOnce(successResponse());
|
|
165
|
+
|
|
166
|
+
await handleBulkOptOut(mockFetchBulkParams, true);
|
|
167
|
+
|
|
168
|
+
expect(store.dispatch).toHaveBeenCalledWith(
|
|
169
|
+
expect.objectContaining({
|
|
170
|
+
type: 'TOASTS_ADD',
|
|
171
|
+
payload: expect.objectContaining({
|
|
172
|
+
message: expect.objectContaining({
|
|
173
|
+
type: 'success',
|
|
174
|
+
}),
|
|
175
|
+
}),
|
|
176
|
+
})
|
|
177
|
+
);
|
|
178
|
+
expect(store.dispatch).toHaveBeenCalledWith(
|
|
179
|
+
expect.objectContaining({
|
|
180
|
+
type: 'TOASTS_ADD',
|
|
181
|
+
payload: expect.objectContaining({
|
|
182
|
+
message: expect.objectContaining({
|
|
183
|
+
type: 'warning',
|
|
184
|
+
message: '2 hosts were skipped because they are not registered',
|
|
185
|
+
}),
|
|
186
|
+
}),
|
|
187
|
+
})
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('shows error toast when fetch hosts fails', async () => {
|
|
192
|
+
global.fetch.mockResolvedValueOnce({
|
|
193
|
+
ok: false,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await handleBulkOptOut(mockFetchBulkParams, true);
|
|
197
|
+
|
|
198
|
+
expect(store.dispatch).toHaveBeenCalledWith(
|
|
199
|
+
expect.objectContaining({
|
|
200
|
+
type: 'TOASTS_ADD',
|
|
201
|
+
payload: expect.objectContaining({
|
|
202
|
+
message: expect.objectContaining({
|
|
203
|
+
type: 'error',
|
|
204
|
+
message: 'Failed to fetch hosts',
|
|
205
|
+
}),
|
|
206
|
+
}),
|
|
207
|
+
})
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('shows error toast when opt_out API fails', async () => {
|
|
212
|
+
global.fetch
|
|
213
|
+
.mockResolvedValueOnce({
|
|
214
|
+
ok: true,
|
|
215
|
+
json: async () => ({ results: [hostWithUuid(1)] }),
|
|
216
|
+
})
|
|
217
|
+
.mockResolvedValueOnce({
|
|
218
|
+
ok: false,
|
|
219
|
+
json: async () => ({
|
|
220
|
+
errors: [{ detail: 'Custom API error message' }],
|
|
221
|
+
}),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await handleBulkOptOut(mockFetchBulkParams, true);
|
|
225
|
+
|
|
226
|
+
expect(store.dispatch).toHaveBeenCalledWith(
|
|
227
|
+
expect.objectContaining({
|
|
228
|
+
type: 'TOASTS_ADD',
|
|
229
|
+
payload: expect.objectContaining({
|
|
230
|
+
message: expect.objectContaining({
|
|
231
|
+
type: 'error',
|
|
232
|
+
message: 'Custom API error message',
|
|
233
|
+
}),
|
|
234
|
+
}),
|
|
235
|
+
})
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('shows generic error message when API error has no detail', async () => {
|
|
240
|
+
global.fetch
|
|
241
|
+
.mockResolvedValueOnce({
|
|
242
|
+
ok: true,
|
|
243
|
+
json: async () => ({ results: [hostWithUuid(1)] }),
|
|
244
|
+
})
|
|
245
|
+
.mockResolvedValueOnce({
|
|
246
|
+
ok: false,
|
|
247
|
+
json: async () => ({}),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await handleBulkOptOut(mockFetchBulkParams, true);
|
|
251
|
+
|
|
252
|
+
expect(store.dispatch).toHaveBeenCalledWith(
|
|
253
|
+
expect.objectContaining({
|
|
254
|
+
type: 'TOASTS_ADD',
|
|
255
|
+
payload: expect.objectContaining({
|
|
256
|
+
message: expect.objectContaining({
|
|
257
|
+
type: 'error',
|
|
258
|
+
message: 'Failed to update vulnerability analysis status',
|
|
259
|
+
}),
|
|
260
|
+
}),
|
|
261
|
+
})
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('updates CVE count cache after successful API call', async () => {
|
|
266
|
+
store.getState.mockReturnValue({
|
|
267
|
+
API: {
|
|
268
|
+
HOST_CVE_COUNT_uuid1: {
|
|
269
|
+
response: { data: [{ attributes: { opt_out: false } }] },
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
global.fetch
|
|
274
|
+
.mockResolvedValueOnce({
|
|
275
|
+
ok: true,
|
|
276
|
+
json: async () => ({ results: [hostWithUuid(1)] }),
|
|
277
|
+
})
|
|
278
|
+
.mockResolvedValueOnce(successResponse());
|
|
279
|
+
|
|
280
|
+
await handleBulkOptOut(mockFetchBulkParams, true);
|
|
281
|
+
|
|
282
|
+
expect(store.dispatch).toHaveBeenCalledWith(
|
|
283
|
+
expect.objectContaining({
|
|
284
|
+
type: 'HOST_CVE_COUNT_uuid1_SUCCESS',
|
|
285
|
+
key: 'HOST_CVE_COUNT_uuid1',
|
|
286
|
+
response: expect.objectContaining({
|
|
287
|
+
data: [
|
|
288
|
+
expect.objectContaining({
|
|
289
|
+
attributes: expect.objectContaining({
|
|
290
|
+
opt_out: true,
|
|
291
|
+
}),
|
|
292
|
+
}),
|
|
293
|
+
],
|
|
294
|
+
}),
|
|
295
|
+
})
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import React, { useContext } from 'react';
|
|
2
|
+
import PropTypes from 'prop-types';
|
|
3
|
+
import { Menu, MenuItem, MenuContent, MenuList } from '@patternfly/react-core';
|
|
4
|
+
import { BanIcon } from '@patternfly/react-icons';
|
|
5
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
6
|
+
import { ForemanHostsIndexActionsBarContext } from 'foremanReact/components/HostsIndex';
|
|
7
|
+
import { useForemanOrganization } from 'foremanReact/Root/Context/ForemanContext';
|
|
8
|
+
import { useIopConfig } from '../common/Hooks/ConfigHooks';
|
|
9
|
+
import { handleBulkOptOut } from './InsightsVulnerabilityActionsBarActions';
|
|
10
|
+
import './InsightsVulnerabilityActionsBar.scss';
|
|
11
|
+
|
|
12
|
+
const DisabledMenuItemDescription = ({ disabledReason }) => (
|
|
13
|
+
<span className="disabled-menu-item-span">
|
|
14
|
+
<span className="disabled-menu-item-icon">
|
|
15
|
+
<BanIcon />
|
|
16
|
+
</span>
|
|
17
|
+
<p className="disabled-menu-item-p">{disabledReason}</p>
|
|
18
|
+
</span>
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
DisabledMenuItemDescription.propTypes = {
|
|
22
|
+
disabledReason: PropTypes.string.isRequired,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const InsightsVulnerabilityActionsBar = () => {
|
|
26
|
+
const isIopEnabled = useIopConfig();
|
|
27
|
+
const { fetchBulkParams, selectedCount, setMenuOpen } = useContext(
|
|
28
|
+
ForemanHostsIndexActionsBarContext
|
|
29
|
+
);
|
|
30
|
+
const orgId = useForemanOrganization()?.id;
|
|
31
|
+
const isDisabled = selectedCount === 0 || !orgId;
|
|
32
|
+
|
|
33
|
+
const handleEnable = () => handleBulkOptOut(fetchBulkParams, false);
|
|
34
|
+
const handleDisable = () => handleBulkOptOut(fetchBulkParams, true);
|
|
35
|
+
|
|
36
|
+
if (!isIopEnabled) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const orgRequiredDescription = __(
|
|
41
|
+
'A specific organization must be selected from the organization context.'
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<MenuItem
|
|
46
|
+
itemId="vulnerability-flyout-item"
|
|
47
|
+
isDisabled={selectedCount === 0}
|
|
48
|
+
flyoutMenu={
|
|
49
|
+
<Menu
|
|
50
|
+
ouiaId="vulnerability-flyout-menu"
|
|
51
|
+
onSelect={() => setMenuOpen(false)}
|
|
52
|
+
>
|
|
53
|
+
<MenuContent>
|
|
54
|
+
<MenuList>
|
|
55
|
+
<MenuItem
|
|
56
|
+
itemId="enable-vulnerability-management-item"
|
|
57
|
+
isDisabled={isDisabled}
|
|
58
|
+
onClick={handleEnable}
|
|
59
|
+
description={
|
|
60
|
+
!orgId && (
|
|
61
|
+
<DisabledMenuItemDescription
|
|
62
|
+
disabledReason={orgRequiredDescription}
|
|
63
|
+
/>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
>
|
|
67
|
+
{__('Enable')}
|
|
68
|
+
</MenuItem>
|
|
69
|
+
<MenuItem
|
|
70
|
+
itemId="disable-vulnerability-management-item"
|
|
71
|
+
isDisabled={isDisabled}
|
|
72
|
+
onClick={handleDisable}
|
|
73
|
+
description={
|
|
74
|
+
!orgId && (
|
|
75
|
+
<DisabledMenuItemDescription
|
|
76
|
+
disabledReason={orgRequiredDescription}
|
|
77
|
+
/>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
>
|
|
81
|
+
{__('Disable')}
|
|
82
|
+
</MenuItem>
|
|
83
|
+
</MenuList>
|
|
84
|
+
</MenuContent>
|
|
85
|
+
</Menu>
|
|
86
|
+
}
|
|
87
|
+
>
|
|
88
|
+
{__('Manage vulnerability analysis')}
|
|
89
|
+
</MenuItem>
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export default InsightsVulnerabilityActionsBar;
|