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,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
+ };
@@ -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;