foreman_openscap 13.0.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 31e3721d3419a9695bb06d27b4dc1f79daf7aa825fe091afdd5ce3ab84fec6d5
4
- data.tar.gz: '09f41b5c13f636a9227ace3b2e2b7c27f5341d22400d98afdcddc597c5650af6'
3
+ metadata.gz: 18cf4b07fd89183f5ced6a907f35a88be64e9a19461dfdfc68511d826e42797e
4
+ data.tar.gz: f581fc51af310ca96c388121914040d7885d2b81d42c14e1982a8efa6501c336
5
5
  SHA512:
6
- metadata.gz: 508fba001dca06e61ae108a10ca54193eec9ab3ba4f26b310481713a852aef69d09c720fdd44ea8ecf89a741a906c30b415640497894f4e0da92b14f0bb5de15
7
- data.tar.gz: ffcc23a0d7bd9ecf624ee81f6e46eadd02fc1bf9111e8bd15648590e9f435a7872e6791f2197d024f680628bdf9de5f6e602c5b13f156984130b2865ad381d34
6
+ metadata.gz: c1c1b44af883638d4bd5c3991dbd40e6ac18d49d85089726d43eca66802727ee91716e2abe3161b5348452e52e8ee97dbd404508381c05482d57dbf10be141d1
7
+ data.tar.gz: 8c3296b8ac590c9da6323a9fc7cff8d4f73a2b0a258bb5bcb3cda6efc6cedc416c66494590987e2893a499fb64be8d44a1c79b214a581fb907758e4751f69971
@@ -0,0 +1,89 @@
1
+ module Api::V2
2
+ module Compliance
3
+ class HostsBulkActionsController < ::Api::V2::BaseController
4
+ include Api::V2::BulkHostsExtension
5
+
6
+ rescue_from ActionController::ParameterMissing do |exception|
7
+ render_error(:custom_error, :status => :unprocessable_entity, :locals => { :message => exception.message })
8
+ end
9
+
10
+ before_action :find_editable_hosts, only: [:change_openscap_proxy]
11
+ before_action :find_openscap_proxy, only: [:change_openscap_proxy]
12
+ before_action :validate_openscap_proxy_feature, only: [:change_openscap_proxy]
13
+
14
+ def_param_group :bulk_host_ids do
15
+ param :included, Hash, :desc => N_("Hosts to include in the action"), :required => true, :action_aware => true do
16
+ param :search, String, :required => false, :desc => N_("Search string describing which hosts to perform the action on")
17
+ param :ids, Array, :required => false, :desc => N_("List of host ids to perform the action on")
18
+ end
19
+ param :excluded, Hash, :desc => N_("Hosts to explicitly exclude in the action."\
20
+ " All other hosts will be included in the action,"\
21
+ " unless an included parameter is passed as well."), :required => true, :action_aware => true do
22
+ param :ids, Array, :required => false, :desc => N_("List of host ids to exclude and not perform the action on")
23
+ end
24
+ end
25
+
26
+ api :PUT, "/hosts/bulk/change_openscap_proxy", N_("Assign OpenSCAP Proxy to multiple hosts")
27
+ param_group :bulk_host_ids
28
+ param :openscap_proxy_id, :number, :required => true, :desc => N_("ID of the OpenSCAP Proxy to assign to the hosts")
29
+ def change_openscap_proxy
30
+ failed_host_ids = []
31
+ host_count = @hosts.count
32
+
33
+ @hosts.find_each do |host|
34
+ host.openscap_proxy = @smart_proxy
35
+ failed_host_ids << host.id unless host.save
36
+ end
37
+
38
+ if failed_host_ids.empty?
39
+ message = _("OpenSCAP Proxy is set to %s") % @smart_proxy.name
40
+ process_response(true, {
41
+ :message => n_("Updated host: #{message}", "Updated hosts: #{message}", host_count),
42
+ })
43
+ else
44
+ failed_count = failed_host_ids.size
45
+ success_count = host_count - failed_count
46
+
47
+ parts = [
48
+ n_("Failed to assign OpenSCAP Proxy to %{failed} of %{total} host.",
49
+ "Failed to assign OpenSCAP Proxy to %{failed} of %{total} hosts.",
50
+ host_count) % { failed: failed_count, total: host_count },
51
+ ]
52
+ if success_count > 0
53
+ parts << n_("Successfully updated %{success} host.",
54
+ "Successfully updated %{success} hosts.",
55
+ success_count) % { success: success_count }
56
+ end
57
+
58
+ render_error(:bulk_hosts_error, :status => :unprocessable_entity,
59
+ :locals => {
60
+ :message => parts.join(' '),
61
+ :failed_host_ids => failed_host_ids,
62
+ })
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def find_editable_hosts
69
+ find_bulk_hosts(:edit_hosts, params)
70
+ end
71
+
72
+ def find_openscap_proxy
73
+ @smart_proxy = ::SmartProxy.authorized(:view_smart_proxies)
74
+ .find_by(:id => params.require(:openscap_proxy_id))
75
+ return if @smart_proxy
76
+
77
+ render_error(:custom_error, :status => :unprocessable_entity,
78
+ :locals => { :message => _("OpenSCAP Proxy with id %s not found") % params[:openscap_proxy_id] })
79
+ end
80
+
81
+ def validate_openscap_proxy_feature
82
+ return if @smart_proxy.has_feature?('Openscap')
83
+
84
+ render_error(:custom_error, :status => :unprocessable_entity,
85
+ :locals => { :message => _("The selected OpenSCAP Proxy does not have the OpenSCAP feature enabled.") })
86
+ end
87
+ end
88
+ end
89
+ end
data/config/routes.rb CHANGED
@@ -103,6 +103,8 @@ Rails.application.routes.draw do
103
103
  end
104
104
  end
105
105
  end
106
+
107
+ match 'hosts/bulk/change_openscap_proxy', :to => 'compliance/hosts_bulk_actions#change_openscap_proxy', :via => [:put]
106
108
  end
107
109
  end
108
110
  end
@@ -93,7 +93,8 @@ module ForemanOpenscap
93
93
  :resource_type => 'ForemanOpenscap::ScapContent'
94
94
  permission :edit_hosts, { :hosts => %i[openscap_proxy_changed
95
95
  select_multiple_openscap_proxy
96
- update_multiple_openscap_proxy] },
96
+ update_multiple_openscap_proxy],
97
+ 'api/v2/compliance/hosts_bulk_actions' => [:change_openscap_proxy] },
97
98
  :resource_type => "Host"
98
99
  permission :view_hosts, { 'api/v2/hosts' => [:policies_enc] }, :resource_type => 'Host'
99
100
  permission :edit_hostgroups, { :hostgroups => [:openscap_proxy_changed] }, :resource_type => "Hostgroup"
@@ -1,3 +1,3 @@
1
1
  module ForemanOpenscap
2
- VERSION = "13.0.1".freeze
2
+ VERSION = '13.1.0'.freeze
3
3
  end
@@ -0,0 +1,132 @@
1
+ require 'test_plugin_helper'
2
+
3
+ class Api::V2::Compliance::HostsBulkActionsControllerTest < ActionController::TestCase
4
+ tests Api::V2::Compliance::HostsBulkActionsController
5
+
6
+ def setup
7
+ as_admin do
8
+ @organization = FactoryBot.create(:organization)
9
+ @location = FactoryBot.create(:location)
10
+ @proxy = FactoryBot.create(:openscap_proxy,
11
+ :organizations => [@organization],
12
+ :locations => [@location])
13
+ @host1 = FactoryBot.create(:host, :managed,
14
+ :organization => @organization,
15
+ :location => @location)
16
+ @host2 = FactoryBot.create(:host, :managed,
17
+ :organization => @organization,
18
+ :location => @location)
19
+ @host_ids = [@host1.id, @host2.id]
20
+ end
21
+ end
22
+
23
+ def valid_bulk_params(host_ids = @host_ids)
24
+ {
25
+ :organization_id => @organization.id,
26
+ :location_id => @location.id,
27
+ :included => {
28
+ :ids => host_ids,
29
+ },
30
+ :excluded => {
31
+ :ids => [],
32
+ },
33
+ }
34
+ end
35
+
36
+ test "should assign openscap proxy to selected hosts" do
37
+ put :change_openscap_proxy,
38
+ params: valid_bulk_params.merge(:openscap_proxy_id => @proxy.id),
39
+ session: set_session_user
40
+
41
+ assert_response :success
42
+ response = ActiveSupport::JSON.decode(@response.body)
43
+ assert_match(/Updated hosts: OpenSCAP Proxy is set to/, response['message'])
44
+ assert_includes response['message'], @proxy.name
45
+
46
+ [@host1, @host2].each do |host|
47
+ host.reload
48
+ assert_equal @proxy.id, host.openscap_proxy_id
49
+ end
50
+ end
51
+
52
+ test "should require openscap_proxy_id" do
53
+ put :change_openscap_proxy,
54
+ params: valid_bulk_params,
55
+ session: set_session_user
56
+
57
+ assert_response :unprocessable_entity
58
+ response = ActiveSupport::JSON.decode(@response.body)
59
+ assert_match(/openscap_proxy_id/, response['error']['message'])
60
+ end
61
+
62
+ test "should return error when proxy is not found" do
63
+ put :change_openscap_proxy,
64
+ params: valid_bulk_params.merge(:openscap_proxy_id => 0),
65
+ session: set_session_user
66
+
67
+ assert_response :unprocessable_entity
68
+ response = ActiveSupport::JSON.decode(@response.body)
69
+ assert_match(/OpenSCAP Proxy with id .* not found/, response['error']['message'])
70
+ end
71
+
72
+ test "should return error when proxy lacks Openscap feature" do
73
+ other_proxy = FactoryBot.create(:smart_proxy,
74
+ :organizations => [@organization],
75
+ :locations => [@location])
76
+ openscap_feature = Feature.find_by(:name => 'Openscap')
77
+ other_proxy.features.delete(openscap_feature) if openscap_feature
78
+ refute other_proxy.reload.has_feature?('Openscap')
79
+
80
+ put :change_openscap_proxy,
81
+ params: valid_bulk_params.merge(:openscap_proxy_id => other_proxy.id),
82
+ session: set_session_user
83
+
84
+ assert_response :unprocessable_entity
85
+ response = ActiveSupport::JSON.decode(@response.body)
86
+ assert_match(/OpenSCAP Proxy does not have the OpenSCAP feature/, response['error']['message'])
87
+ end
88
+
89
+ test "should assign openscap proxy for a single host" do
90
+ put :change_openscap_proxy,
91
+ params: valid_bulk_params([@host1.id]).merge(:openscap_proxy_id => @proxy.id),
92
+ session: set_session_user
93
+
94
+ assert_response :success
95
+ response = ActiveSupport::JSON.decode(@response.body)
96
+ assert_match(/Updated host: OpenSCAP Proxy is set to/, response['message'])
97
+
98
+ @host1.reload
99
+ assert_equal @proxy.id, @host1.openscap_proxy_id
100
+ @host2.reload
101
+ assert_nil @host2.openscap_proxy_id
102
+ end
103
+
104
+ test "should report failed and successful counts on partial failure" do
105
+ Host.any_instance.stubs(:save).returns(false).then.returns(true)
106
+
107
+ put :change_openscap_proxy,
108
+ params: valid_bulk_params.merge(:openscap_proxy_id => @proxy.id),
109
+ session: set_session_user
110
+
111
+ assert_response :unprocessable_entity
112
+ response = ActiveSupport::JSON.decode(@response.body)
113
+ assert_match(/Failed to assign OpenSCAP Proxy to 1 of 2 hosts/, response['error']['message'])
114
+ assert_match(/Successfully updated 1 host/, response['error']['message'])
115
+ assert_equal 1, response['error']['failed_host_ids'].size
116
+ assert_includes @host_ids, response['error']['failed_host_ids'].first
117
+ end
118
+
119
+ test "should report only failures when all hosts fail" do
120
+ Host.any_instance.stubs(:save).returns(false)
121
+
122
+ put :change_openscap_proxy,
123
+ params: valid_bulk_params.merge(:openscap_proxy_id => @proxy.id),
124
+ session: set_session_user
125
+
126
+ assert_response :unprocessable_entity
127
+ response = ActiveSupport::JSON.decode(@response.body)
128
+ assert_match(/Failed to assign OpenSCAP Proxy to 2 of 2 hosts/, response['error']['message'])
129
+ refute_match(/Successfully updated/, response['error']['message'])
130
+ assert_equal @host_ids.sort, response['error']['failed_host_ids'].sort
131
+ end
132
+ end
@@ -0,0 +1,227 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { useDispatch, useSelector } from 'react-redux';
4
+ import {
5
+ Modal,
6
+ Button,
7
+ Grid,
8
+ GridItem,
9
+ Form,
10
+ FormGroup,
11
+ Stack,
12
+ StackItem,
13
+ Skeleton,
14
+ } from '@patternfly/react-core';
15
+ import { SimpleDropdown } from '@patternfly/react-templates';
16
+ import { foremanUrl } from 'foremanReact/common/helpers';
17
+ import { APIActions } from 'foremanReact/redux/API';
18
+ import { sprintf, translate as __ } from 'foremanReact/common/I18n';
19
+ import { STATUS } from 'foremanReact/constants';
20
+ import {
21
+ selectAPIStatus,
22
+ selectAPIResponse,
23
+ } from 'foremanReact/redux/API/APISelectors';
24
+ import { buildBulkRequestBody } from 'foremanReact/components/HostsIndex/BulkActions/helpers';
25
+ import {
26
+ BULK_CHANGE_OPENSCAP_PROXY_KEY,
27
+ OPENSCAP_PROXIES_KEY,
28
+ } from '../../../OpenscapRemediationWizard/constants';
29
+
30
+ const fetchOpenscapProxies = () =>
31
+ APIActions.get({
32
+ key: OPENSCAP_PROXIES_KEY,
33
+ url: foremanUrl(
34
+ '/api/smart_proxies?search=feature%3DOpenscap&per_page=all'
35
+ ),
36
+ });
37
+
38
+ const BulkChangeOpenscapProxyModal = ({
39
+ isOpen,
40
+ closeModal,
41
+ selectAllHostsMode,
42
+ selectedCount,
43
+ fetchBulkParams,
44
+ organizationId,
45
+ locationId,
46
+ onSuccess: onSuccessCallback,
47
+ }) => {
48
+ const dispatch = useDispatch();
49
+ const [proxyId, setProxyId] = useState('');
50
+ const [isSubmitting, setIsSubmitting] = useState(false);
51
+
52
+ useEffect(() => {
53
+ if (isOpen) {
54
+ dispatch(fetchOpenscapProxies());
55
+ } else {
56
+ setIsSubmitting(false);
57
+ }
58
+ }, [dispatch, isOpen]);
59
+
60
+ const proxies = useSelector(state =>
61
+ selectAPIResponse(state, OPENSCAP_PROXIES_KEY)
62
+ );
63
+ const proxyStatus = useSelector(state =>
64
+ selectAPIStatus(state, OPENSCAP_PROXIES_KEY)
65
+ );
66
+
67
+ const handleModalClose = () => {
68
+ setProxyId('');
69
+ setIsSubmitting(false);
70
+ closeModal();
71
+ };
72
+
73
+ const handleSuccess = () => {
74
+ handleModalClose();
75
+ if (onSuccessCallback) onSuccessCallback();
76
+ };
77
+
78
+ const handleError = () => {
79
+ setIsSubmitting(false);
80
+ handleModalClose();
81
+ };
82
+
83
+ const handleConfirm = () => {
84
+ const requestBody = buildBulkRequestBody({
85
+ fetchBulkParams,
86
+ organizationId,
87
+ locationId,
88
+ openscap_proxy_id: proxyId,
89
+ });
90
+
91
+ setIsSubmitting(true);
92
+ dispatch(
93
+ APIActions.put({
94
+ key: BULK_CHANGE_OPENSCAP_PROXY_KEY,
95
+ url: foremanUrl('/api/v2/hosts/bulk/change_openscap_proxy'),
96
+ handleSuccess,
97
+ successToast: response => response.data.message,
98
+ handleError,
99
+ errorToast: error => error?.response?.data?.error?.message,
100
+ params: requestBody,
101
+ })
102
+ );
103
+ };
104
+
105
+ const descriptionText = selectAllHostsMode ? (
106
+ <>
107
+ {__('Assign OpenSCAP Proxy for ')}
108
+ <strong>{__('ALL selected hosts.')}</strong>
109
+ <br />
110
+ {__('This will change previous proxy assignments on the selected hosts.')}
111
+ </>
112
+ ) : (
113
+ <>
114
+ {__('Assign OpenSCAP Proxy for ')}
115
+ <strong>{sprintf(__('%s selected hosts.'), selectedCount)}</strong>
116
+ <br />
117
+ {__('This will change previous proxy assignments on the selected hosts.')}
118
+ </>
119
+ );
120
+
121
+ const getProxyLabel = id => {
122
+ const proxy = proxies?.results?.find(
123
+ p => p.id.toString() === id.toString()
124
+ );
125
+ return proxy?.name || id;
126
+ };
127
+
128
+ const proxyItems =
129
+ proxies?.results?.map(proxy => ({
130
+ value: proxy.id.toString(),
131
+ content: proxy.name,
132
+ onClick: () => setProxyId(proxy.id.toString()),
133
+ })) || [];
134
+
135
+ const modalActions = [
136
+ <Button
137
+ key="confirm"
138
+ ouiaId="bulk-change-openscap-proxy-modal-confirm-button"
139
+ variant="primary"
140
+ onClick={handleConfirm}
141
+ isDisabled={proxyId === '' || isSubmitting}
142
+ isLoading={isSubmitting}
143
+ spinnerAriaLabel={__('Loading')}
144
+ >
145
+ {__('Assign')}
146
+ </Button>,
147
+ <Button
148
+ key="cancel"
149
+ ouiaId="bulk-change-openscap-proxy-modal-cancel-button"
150
+ variant="link"
151
+ onClick={handleModalClose}
152
+ isDisabled={isSubmitting}
153
+ >
154
+ {__('Cancel')}
155
+ </Button>,
156
+ ];
157
+
158
+ return (
159
+ <Modal
160
+ isOpen={isOpen}
161
+ onClose={handleModalClose}
162
+ onEscapePress={handleModalClose}
163
+ title={__('Assign OpenSCAP Proxy')}
164
+ variant="small"
165
+ position="top"
166
+ actions={modalActions}
167
+ id="bulk-change-openscap-proxy-modal"
168
+ key="bulk-change-openscap-proxy-modal"
169
+ ouiaId="bulk-change-openscap-proxy-modal"
170
+ >
171
+ <Stack hasGutter>
172
+ <StackItem>{descriptionText}</StackItem>
173
+ {proxyStatus === STATUS.RESOLVED && proxies?.results?.length > 0 && (
174
+ <StackItem>
175
+ <Grid>
176
+ <GridItem span={8}>
177
+ <Form>
178
+ <FormGroup label={__('Select OpenSCAP Proxy')}>
179
+ <SimpleDropdown
180
+ id="openscap-proxy-select"
181
+ ouiaId="bulk-change-openscap-proxy-select"
182
+ toggleContent={
183
+ proxyId
184
+ ? getProxyLabel(proxyId)
185
+ : __('Select OpenSCAP Proxy')
186
+ }
187
+ initialItems={proxyItems}
188
+ />
189
+ </FormGroup>
190
+ </Form>
191
+ </GridItem>
192
+ </Grid>
193
+ </StackItem>
194
+ )}
195
+ {proxyStatus === STATUS.RESOLVED &&
196
+ (!proxies?.results || proxies.results.length === 0) &&
197
+ __(
198
+ 'No OpenSCAP Proxies available. Please configure a Smart Proxy with the OpenSCAP feature.'
199
+ )}
200
+ {proxyStatus === STATUS.PENDING && (
201
+ <Skeleton screenreaderText="Loading contents" />
202
+ )}
203
+ </Stack>
204
+ </Modal>
205
+ );
206
+ };
207
+
208
+ BulkChangeOpenscapProxyModal.propTypes = {
209
+ isOpen: PropTypes.bool,
210
+ closeModal: PropTypes.func,
211
+ fetchBulkParams: PropTypes.func.isRequired,
212
+ selectedCount: PropTypes.number.isRequired,
213
+ selectAllHostsMode: PropTypes.bool.isRequired,
214
+ organizationId: PropTypes.number,
215
+ locationId: PropTypes.number,
216
+ onSuccess: PropTypes.func,
217
+ };
218
+
219
+ BulkChangeOpenscapProxyModal.defaultProps = {
220
+ isOpen: false,
221
+ closeModal: () => {},
222
+ organizationId: undefined,
223
+ locationId: undefined,
224
+ onSuccess: undefined,
225
+ };
226
+
227
+ export default BulkChangeOpenscapProxyModal;
@@ -0,0 +1,198 @@
1
+ import React from 'react';
2
+ import {
3
+ screen,
4
+ fireEvent,
5
+ waitFor,
6
+ within,
7
+ act,
8
+ } from '@testing-library/react';
9
+ import '@testing-library/jest-dom';
10
+ import { rtlHelpers, initMockStore } from 'foremanReact/common/testHelpers';
11
+ import { STATUS } from 'foremanReact/constants';
12
+ import { APIActions } from 'foremanReact/redux/API';
13
+ import { OPENSCAP_PROXIES_KEY } from '../../../../OpenscapRemediationWizard/constants';
14
+ import BulkChangeOpenscapProxyModal from '../BulkChangeOpenscapProxyModal';
15
+
16
+ const { renderWithStore } = rtlHelpers;
17
+
18
+ jest.mock('foremanReact/common/I18n');
19
+
20
+ jest.spyOn(APIActions, 'get');
21
+ jest.spyOn(APIActions, 'put');
22
+
23
+ const proxies = {
24
+ results: [
25
+ { id: 1, name: 'openscap-proxy-1.example.com' },
26
+ { id: 2, name: 'openscap-proxy-2.example.com' },
27
+ ],
28
+ };
29
+
30
+ const defaultProps = {
31
+ selectedCount: 3,
32
+ selectAllHostsMode: false,
33
+ fetchBulkParams: jest.fn(() => 'id ^ (1,2,3)'),
34
+ isOpen: true,
35
+ closeModal: jest.fn(),
36
+ organizationId: 1,
37
+ locationId: 2,
38
+ };
39
+
40
+ const proxiesResolvedState = {
41
+ API: {
42
+ [OPENSCAP_PROXIES_KEY]: {
43
+ status: STATUS.RESOLVED,
44
+ response: proxies,
45
+ },
46
+ },
47
+ };
48
+
49
+ const noProxiesState = {
50
+ API: {
51
+ [OPENSCAP_PROXIES_KEY]: {
52
+ status: STATUS.RESOLVED,
53
+ response: { results: [] },
54
+ },
55
+ },
56
+ };
57
+
58
+ const renderModal = (props = {}, initialState = proxiesResolvedState) =>
59
+ renderWithStore(
60
+ <BulkChangeOpenscapProxyModal {...defaultProps} {...props} />,
61
+ initialState
62
+ );
63
+
64
+ describe('BulkChangeOpenscapProxyModal', () => {
65
+ beforeEach(() => {
66
+ jest.clearAllMocks();
67
+ // renderWithStore lodash-merges into shared initMockStore; clear prior API fixtures
68
+ delete initMockStore.API[OPENSCAP_PROXIES_KEY];
69
+ APIActions.get.mockImplementation(payload => ({
70
+ type: 'TEST_API_GET',
71
+ payload,
72
+ }));
73
+ APIActions.put.mockImplementation(payload => ({
74
+ type: 'TEST_API_PUT',
75
+ payload,
76
+ }));
77
+ });
78
+
79
+ it('renders modal title and description with selected host count', () => {
80
+ renderModal();
81
+ expect(screen.getByText('Assign OpenSCAP Proxy')).toBeInTheDocument();
82
+ expect(screen.getByText('3 selected hosts.')).toBeInTheDocument();
83
+ });
84
+
85
+ it('renders all-hosts mode description', () => {
86
+ renderModal({ selectAllHostsMode: true });
87
+ expect(screen.getByText('ALL selected hosts.')).toBeInTheDocument();
88
+ });
89
+
90
+ it('disables Assign until a proxy is selected', () => {
91
+ renderModal();
92
+ expect(screen.getByRole('button', { name: 'Assign' })).toBeDisabled();
93
+ });
94
+
95
+ it('lists OpenSCAP proxies in the dropdown', async () => {
96
+ renderModal();
97
+ fireEvent.click(
98
+ screen.getByRole('button', { name: 'Select OpenSCAP Proxy' })
99
+ );
100
+
101
+ const menu = await screen.findByRole('menu');
102
+ expect(
103
+ within(menu).getByRole('menuitem', {
104
+ name: 'openscap-proxy-1.example.com',
105
+ })
106
+ ).toBeInTheDocument();
107
+ expect(
108
+ within(menu).getByRole('menuitem', {
109
+ name: 'openscap-proxy-2.example.com',
110
+ })
111
+ ).toBeInTheDocument();
112
+ });
113
+
114
+ it('enables Assign and dispatches bulk PUT after selecting a proxy', async () => {
115
+ renderModal();
116
+ fireEvent.click(
117
+ screen.getByRole('button', { name: 'Select OpenSCAP Proxy' })
118
+ );
119
+
120
+ const menu = await screen.findByRole('menu');
121
+ fireEvent.click(
122
+ within(menu).getByRole('menuitem', {
123
+ name: 'openscap-proxy-1.example.com',
124
+ })
125
+ );
126
+
127
+ const assignBtn = screen.getByRole('button', { name: 'Assign' });
128
+ await waitFor(() => {
129
+ expect(assignBtn).not.toBeDisabled();
130
+ });
131
+
132
+ fireEvent.click(assignBtn);
133
+ expect(APIActions.put).toHaveBeenCalledWith(
134
+ expect.objectContaining({
135
+ url: expect.stringContaining('/hosts/bulk/change_openscap_proxy'),
136
+ params: expect.objectContaining({
137
+ included: { search: 'id ^ (1,2,3)' },
138
+ organization_id: 1,
139
+ location_id: 2,
140
+ openscap_proxy_id: '1',
141
+ }),
142
+ })
143
+ );
144
+ });
145
+
146
+ it('calls onSuccess and closeModal after a successful bulk PUT', async () => {
147
+ const onSuccess = jest.fn();
148
+ const closeModal = jest.fn();
149
+ renderModal({ onSuccess, closeModal });
150
+
151
+ fireEvent.click(
152
+ screen.getByRole('button', { name: 'Select OpenSCAP Proxy' })
153
+ );
154
+
155
+ const menu = await screen.findByRole('menu');
156
+ fireEvent.click(
157
+ within(menu).getByRole('menuitem', {
158
+ name: 'openscap-proxy-1.example.com',
159
+ })
160
+ );
161
+
162
+ const assignBtn = screen.getByRole('button', { name: 'Assign' });
163
+ await waitFor(() => {
164
+ expect(assignBtn).not.toBeDisabled();
165
+ });
166
+
167
+ fireEvent.click(assignBtn);
168
+
169
+ const { handleSuccess } = APIActions.put.mock.calls[0][0];
170
+ act(() => {
171
+ handleSuccess();
172
+ });
173
+
174
+ expect(onSuccess).toHaveBeenCalled();
175
+ expect(closeModal).toHaveBeenCalled();
176
+ });
177
+
178
+ it('calls closeModal on Cancel', () => {
179
+ const closeModal = jest.fn();
180
+ renderModal({ closeModal });
181
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
182
+ expect(closeModal).toHaveBeenCalled();
183
+ });
184
+
185
+ it('shows empty state when no proxies are available', () => {
186
+ renderModal({}, noProxiesState);
187
+ expect(
188
+ screen.getByText(
189
+ 'No OpenSCAP Proxies available. Please configure a Smart Proxy with the OpenSCAP feature.'
190
+ )
191
+ ).toBeInTheDocument();
192
+ });
193
+
194
+ it('does not render when isOpen is false', () => {
195
+ renderModal({ isOpen: false });
196
+ expect(screen.queryByText('Assign OpenSCAP Proxy')).not.toBeInTheDocument();
197
+ });
198
+ });
@@ -0,0 +1,65 @@
1
+ import React, { useContext } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { MenuItem } from '@patternfly/react-core';
4
+ import { translate as __ } from 'foremanReact/common/I18n';
5
+ import {
6
+ openBulkModal,
7
+ useBulkModalOpen,
8
+ } from 'foremanReact/common/BulkModalStateHelper';
9
+ import { ForemanActionsBarContext } from 'foremanReact/components/HostDetails/ActionsBar';
10
+ import BulkChangeOpenscapProxyModal from './BulkActions/changeOpenscapProxy/BulkChangeOpenscapProxyModal';
11
+ import { CHANGE_OPENSCAP_MODAL_ID } from '../OpenscapRemediationWizard/constants';
12
+
13
+ export const ChangeOpenscapProxyMenuItem = ({ selectedCount }) => {
14
+ const openModal = () => openBulkModal(CHANGE_OPENSCAP_MODAL_ID, true);
15
+
16
+ return (
17
+ <MenuItem
18
+ itemId="change-openscap-proxy-dropdown-item"
19
+ key="change-openscap-proxy-dropdown-item"
20
+ onClick={openModal}
21
+ isDisabled={selectedCount === 0}
22
+ >
23
+ {__('OpenSCAP Proxy')}
24
+ </MenuItem>
25
+ );
26
+ };
27
+
28
+ ChangeOpenscapProxyMenuItem.propTypes = {
29
+ selectedCount: PropTypes.number,
30
+ };
31
+
32
+ ChangeOpenscapProxyMenuItem.defaultProps = {
33
+ selectedCount: 0,
34
+ };
35
+
36
+ const BulkChangeOpenscapProxyModalScene = () => {
37
+ const {
38
+ selectAllHostsMode = false,
39
+ selectedCount = 0,
40
+ fetchBulkParams,
41
+ organizationId,
42
+ locationId,
43
+ refreshTableData,
44
+ } = useContext(ForemanActionsBarContext) || {};
45
+
46
+ const { isOpen, close: closeModal } = useBulkModalOpen(
47
+ CHANGE_OPENSCAP_MODAL_ID
48
+ );
49
+
50
+ return (
51
+ <BulkChangeOpenscapProxyModal
52
+ key="bulk-change-openscap-proxy-modal"
53
+ selectAllHostsMode={selectAllHostsMode}
54
+ selectedCount={selectedCount}
55
+ fetchBulkParams={fetchBulkParams}
56
+ organizationId={organizationId}
57
+ locationId={locationId}
58
+ isOpen={isOpen}
59
+ closeModal={closeModal}
60
+ onSuccess={refreshTableData}
61
+ />
62
+ );
63
+ };
64
+
65
+ export default BulkChangeOpenscapProxyModalScene;
@@ -0,0 +1,172 @@
1
+ import React from 'react';
2
+ import { screen, fireEvent } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
4
+ import { Menu, MenuContent, MenuList } from '@patternfly/react-core';
5
+ import { rtlHelpers, initMockStore } from 'foremanReact/common/testHelpers';
6
+ import { openBulkModal } from 'foremanReact/common/BulkModalStateHelper';
7
+ import { ForemanActionsBarContext } from 'foremanReact/components/HostDetails/ActionsBar';
8
+ import { STATUS } from 'foremanReact/constants';
9
+ import { APIActions } from 'foremanReact/redux/API';
10
+ import {
11
+ CHANGE_OPENSCAP_MODAL_ID,
12
+ OPENSCAP_PROXIES_KEY,
13
+ } from '../../OpenscapRemediationWizard/constants';
14
+ import BulkChangeOpenscapProxyModalScene, {
15
+ ChangeOpenscapProxyMenuItem,
16
+ } from '../ChangeOpenscapProxyAction';
17
+
18
+ const { renderWithStore } = rtlHelpers;
19
+
20
+ jest.spyOn(APIActions, 'get');
21
+ jest.spyOn(APIActions, 'put');
22
+
23
+ const proxiesResolvedState = {
24
+ API: {
25
+ [OPENSCAP_PROXIES_KEY]: {
26
+ status: STATUS.RESOLVED,
27
+ response: {
28
+ results: [
29
+ { id: 1, name: 'openscap-proxy-1.example.com' },
30
+ { id: 2, name: 'openscap-proxy-2.example.com' },
31
+ ],
32
+ },
33
+ },
34
+ },
35
+ };
36
+
37
+ const actionsBarValue = {
38
+ selectAllHostsMode: false,
39
+ selectedCount: 3,
40
+ fetchBulkParams: jest.fn(() => 'id ^ (1,2,3)'),
41
+ organizationId: 1,
42
+ locationId: 2,
43
+ };
44
+
45
+ const renderMenuItem = (props = {}) =>
46
+ renderWithStore(
47
+ // eslint-disable-next-line @theforeman/rules/require-ouiaid
48
+ <Menu activeItemId={null}>
49
+ <MenuContent>
50
+ <MenuList>
51
+ <ChangeOpenscapProxyMenuItem selectedCount={3} {...props} />
52
+ </MenuList>
53
+ </MenuContent>
54
+ </Menu>
55
+ );
56
+
57
+ const renderScene = (contextValue = {}, initialState = proxiesResolvedState) =>
58
+ renderWithStore(
59
+ <ForemanActionsBarContext.Provider
60
+ value={{ ...actionsBarValue, ...contextValue }}
61
+ >
62
+ <BulkChangeOpenscapProxyModalScene />
63
+ </ForemanActionsBarContext.Provider>,
64
+ initialState
65
+ );
66
+
67
+ const renderMenuItemWithScene = (
68
+ menuProps = {},
69
+ contextValue = {},
70
+ initialState = proxiesResolvedState
71
+ ) =>
72
+ renderWithStore(
73
+ <ForemanActionsBarContext.Provider
74
+ value={{ ...actionsBarValue, ...contextValue }}
75
+ >
76
+ {/* eslint-disable-next-line @theforeman/rules/require-ouiaid */}
77
+ <Menu activeItemId={null}>
78
+ <MenuContent>
79
+ <MenuList>
80
+ <ChangeOpenscapProxyMenuItem selectedCount={3} {...menuProps} />
81
+ </MenuList>
82
+ </MenuContent>
83
+ </Menu>
84
+ <BulkChangeOpenscapProxyModalScene />
85
+ </ForemanActionsBarContext.Provider>,
86
+ initialState
87
+ );
88
+
89
+ describe('ChangeOpenscapProxyMenuItem', () => {
90
+ beforeEach(() => {
91
+ jest.clearAllMocks();
92
+ delete initMockStore.API[OPENSCAP_PROXIES_KEY];
93
+ openBulkModal(CHANGE_OPENSCAP_MODAL_ID, false);
94
+ APIActions.get.mockImplementation(payload => ({
95
+ type: 'TEST_API_GET',
96
+ payload,
97
+ }));
98
+ APIActions.put.mockImplementation(payload => ({
99
+ type: 'TEST_API_PUT',
100
+ payload,
101
+ }));
102
+ });
103
+
104
+ it('renders the OpenSCAP Proxy menu item', () => {
105
+ renderMenuItem();
106
+ expect(screen.getByText('OpenSCAP Proxy')).toBeInTheDocument();
107
+ });
108
+
109
+ it('is disabled when no hosts are selected', () => {
110
+ renderMenuItem({ selectedCount: 0 });
111
+ expect(
112
+ screen.getByRole('menuitem', { name: 'OpenSCAP Proxy' })
113
+ ).toBeDisabled();
114
+ });
115
+
116
+ it('opens the bulk modal when clicked', () => {
117
+ renderMenuItemWithScene({ selectedCount: 2 });
118
+
119
+ expect(screen.queryByText('Assign OpenSCAP Proxy')).not.toBeInTheDocument();
120
+
121
+ fireEvent.click(screen.getByText('OpenSCAP Proxy'));
122
+
123
+ expect(screen.getByText('Assign OpenSCAP Proxy')).toBeInTheDocument();
124
+ });
125
+ });
126
+
127
+ describe('BulkChangeOpenscapProxyModalScene', () => {
128
+ beforeEach(() => {
129
+ jest.clearAllMocks();
130
+ delete initMockStore.API[OPENSCAP_PROXIES_KEY];
131
+ openBulkModal(CHANGE_OPENSCAP_MODAL_ID, false);
132
+ APIActions.get.mockImplementation(payload => ({
133
+ type: 'TEST_API_GET',
134
+ payload,
135
+ }));
136
+ APIActions.put.mockImplementation(payload => ({
137
+ type: 'TEST_API_PUT',
138
+ payload,
139
+ }));
140
+ });
141
+
142
+ it('does not render the modal when closed', () => {
143
+ renderScene();
144
+ expect(screen.queryByText('Assign OpenSCAP Proxy')).not.toBeInTheDocument();
145
+ });
146
+
147
+ it('passes actions-bar context props when the modal is open', () => {
148
+ openBulkModal(CHANGE_OPENSCAP_MODAL_ID, true);
149
+ renderScene({
150
+ selectAllHostsMode: true,
151
+ selectedCount: 5,
152
+ organizationId: 10,
153
+ locationId: 20,
154
+ });
155
+
156
+ expect(screen.getByText('Assign OpenSCAP Proxy')).toBeInTheDocument();
157
+ expect(screen.getByText('ALL selected hosts.')).toBeInTheDocument();
158
+ expect(
159
+ screen.getByRole('button', { name: 'Select OpenSCAP Proxy' })
160
+ ).toBeInTheDocument();
161
+ });
162
+
163
+ it('closes the modal via Cancel', () => {
164
+ openBulkModal(CHANGE_OPENSCAP_MODAL_ID, true);
165
+ renderScene();
166
+
167
+ expect(screen.getByText('Assign OpenSCAP Proxy')).toBeInTheDocument();
168
+
169
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
170
+ expect(screen.queryByText('Assign OpenSCAP Proxy')).not.toBeInTheDocument();
171
+ });
172
+ });
@@ -1,7 +1,5 @@
1
1
  import { translate as __ } from 'foremanReact/common/I18n';
2
2
 
3
- export const OPENSCAP_REMEDIATION_MODAL_ID = 'openscapRemediationModal';
4
- export const HOSTS_PATH = '/hosts';
5
3
  export const FAIL_RULE_SEARCH = 'fails_xccdf_rule';
6
4
 
7
5
  export const HOSTS_API_PATH = '/api/hosts';
@@ -24,3 +22,8 @@ export const WIZARD_TITLES = {
24
22
  reviewRemediation: __('Review remediation'),
25
23
  finish: __('Done'),
26
24
  };
25
+
26
+ export const BULK_CHANGE_OPENSCAP_PROXY_KEY = 'BULK_CHANGE_OPENSCAP_PROXY';
27
+ export const OPENSCAP_PROXIES_KEY = 'OPENSCAP_PROXIES_KEY';
28
+
29
+ export const CHANGE_OPENSCAP_MODAL_ID = 'BULK_CHANGE_OPENSCAP_PROXY_MODAL';
@@ -1,13 +1,40 @@
1
- import { testComponentSnapshotsWithFixtures } from '@theforeman/test';
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
2
4
 
3
5
  import RuleSeverity from './index';
4
6
 
5
- const levels = ['Low', 'Medium', 'High', 'Critical', 'foo'];
7
+ jest.mock('./i_severity-critical.svg', () => 'critical.svg');
8
+ jest.mock('./i_severity-high.svg', () => 'high.svg');
9
+ jest.mock('./i_severity-med.svg', () => 'med.svg');
10
+ jest.mock('./i_severity-low.svg', () => 'low.svg');
11
+ jest.mock('./i_unknown.svg', () => 'unknown.svg');
6
12
 
7
- const fixtures = levels.reduce((memo, level) => {
8
- memo[`should render for ${level} severity`] = { severity: level };
9
- return memo;
10
- }, {});
13
+ describe('RuleSeverity', () => {
14
+ it.each([
15
+ ['low', 'Low Severity', 'low.svg'],
16
+ ['medium', 'Medium Severity', 'med.svg'],
17
+ ['high', 'High Severity', 'high.svg'],
18
+ ['critical', 'Critical Severity', 'critical.svg'],
19
+ ['unknown', 'Unknown Severity', 'unknown.svg'],
20
+ ])('renders the %s severity icon', (severity, altText, iconSrc) => {
21
+ render(<RuleSeverity severity={severity} />);
11
22
 
12
- describe('RuleSeverity', () =>
13
- testComponentSnapshotsWithFixtures(RuleSeverity, fixtures));
23
+ const icon = screen.getByRole('img', { name: altText });
24
+
25
+ expect(icon).toBeInTheDocument();
26
+ expect(icon).toHaveAttribute('src', iconSrc);
27
+ });
28
+
29
+ it.each(['foo', 'Low', 'Medium', 'High', 'Critical'])(
30
+ 'renders the unknown severity icon for unrecognized severity %s',
31
+ severity => {
32
+ render(<RuleSeverity severity={severity} />);
33
+
34
+ const icon = screen.getByRole('img', { name: 'Unknown Severity' });
35
+
36
+ expect(icon).toBeInTheDocument();
37
+ expect(icon).toHaveAttribute('src', 'unknown.svg');
38
+ }
39
+ );
40
+ });
@@ -11,14 +11,14 @@ import './RuleSeverity.scss';
11
11
 
12
12
  const RuleSeverity = props => {
13
13
  const propsMapping = {
14
- low: { alt: 'Low Serverity', src: SeverityLow },
15
- medium: { alt: 'Medium Serverity', src: SeverityMedium },
16
- high: { alt: 'High Serverity', src: SeverityHigh },
14
+ low: { alt: 'Low Severity', src: SeverityLow },
15
+ medium: { alt: 'Medium Severity', src: SeverityMedium },
16
+ high: { alt: 'High Severity', src: SeverityHigh },
17
17
  critical: {
18
- alt: 'Critical Serverity',
18
+ alt: 'Critical Severity',
19
19
  src: SeverityCritical,
20
20
  },
21
- unknown: { alt: 'Unknown Serverity', src: SeverityUnknown },
21
+ unknown: { alt: 'Unknown Severity', src: SeverityUnknown },
22
22
  };
23
23
 
24
24
  const imgProps = propsMapping[props.severity] || propsMapping.unknown;
@@ -1,6 +1,12 @@
1
1
  import React from 'react';
2
2
  import { addGlobalFill } from 'foremanReact/components/common/Fill/GlobalFill';
3
3
  import HostKebabItems from './components/HostExtentions/HostKebabItems';
4
+ import BulkChangeOpenscapProxyModalScene, {
5
+ ChangeOpenscapProxyMenuItem,
6
+ } from './components/HostsIndex/ChangeOpenscapProxyAction';
7
+
8
+ const HOST_ASSOCIATIONS_WEIGHT = 1212;
9
+ const BULK_MODAL_WEIGHT = 100;
4
10
 
5
11
  const OPENSCAP_KEBAB_WEIGHT = 400;
6
12
 
@@ -10,3 +16,17 @@ addGlobalFill(
10
16
  <HostKebabItems key="openscap-host-kebab" />,
11
17
  OPENSCAP_KEBAB_WEIGHT
12
18
  );
19
+
20
+ addGlobalFill(
21
+ '_host-associations',
22
+ 'openscap-change-proxy-menu-item',
23
+ <ChangeOpenscapProxyMenuItem key="openscap-change-proxy-menu-item" />,
24
+ HOST_ASSOCIATIONS_WEIGHT
25
+ );
26
+
27
+ addGlobalFill(
28
+ '_all-hosts-modals',
29
+ 'BulkChangeOpenscapProxyModal',
30
+ <BulkChangeOpenscapProxyModalScene key="bulk-change-openscap-proxy-modal" />,
31
+ BULK_MODAL_WEIGHT
32
+ );
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foreman_openscap
3
3
  version: !ruby/object:Gem::Version
4
- version: 13.0.1
4
+ version: 13.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - slukasik@redhat.com
@@ -45,6 +45,7 @@ files:
45
45
  - app/assets/stylesheets/foreman_openscap/reports.css
46
46
  - app/assets/stylesheets/foreman_openscap/scap_breakdown_chart.css
47
47
  - app/controllers/api/v2/compliance/arf_reports_controller.rb
48
+ - app/controllers/api/v2/compliance/hosts_bulk_actions_controller.rb
48
49
  - app/controllers/api/v2/compliance/policies_controller.rb
49
50
  - app/controllers/api/v2/compliance/scap_content_profiles_controller.rb
50
51
  - app/controllers/api/v2/compliance/scap_contents_controller.rb
@@ -333,6 +334,7 @@ files:
333
334
  - test/files/tailoring_files/ssg-firefox-ds-tailoring-2.xml
334
335
  - test/files/tailoring_files/ssg-firefox-ds-tailoring.xml
335
336
  - test/functional/api/v2/compliance/arf_reports_controller_test.rb
337
+ - test/functional/api/v2/compliance/hosts_bulk_actions_controller_test.rb
336
338
  - test/functional/api/v2/compliance/policies_controller_test.rb
337
339
  - test/functional/api/v2/compliance/scap_content_profiles_controller_test.rb
338
340
  - test/functional/api/v2/compliance/scap_contents_controller_test.rb
@@ -364,6 +366,10 @@ files:
364
366
  - test/unit/tailoring_file_test.rb
365
367
  - webpack/components/EmptyState.js
366
368
  - webpack/components/HostExtentions/HostKebabItems.js
369
+ - webpack/components/HostsIndex/BulkActions/changeOpenscapProxy/BulkChangeOpenscapProxyModal.js
370
+ - webpack/components/HostsIndex/BulkActions/changeOpenscapProxy/__tests__/BulkChangeOpenscapProxyModal.test.js
371
+ - webpack/components/HostsIndex/ChangeOpenscapProxyAction.js
372
+ - webpack/components/HostsIndex/__tests__/ChangeOpenscapProxyAction.test.js
367
373
  - webpack/components/IndexLayout.scss
368
374
  - webpack/components/LineChart/LineChart.fixtures.js
369
375
  - webpack/components/LineChart/LineChart.scss
@@ -386,7 +392,6 @@ files:
386
392
  - webpack/components/OpenscapRemediationWizard/steps/index.js
387
393
  - webpack/components/RuleSeverity/RuleSeverity.scss
388
394
  - webpack/components/RuleSeverity/RuleSeverity.test.js
389
- - webpack/components/RuleSeverity/__snapshots__/RuleSeverity.test.js.snap
390
395
  - webpack/components/RuleSeverity/i_severity-critical.svg
391
396
  - webpack/components/RuleSeverity/i_severity-high.svg
392
397
  - webpack/components/RuleSeverity/i_severity-low.svg
@@ -437,6 +442,7 @@ test_files:
437
442
  - test/files/tailoring_files/ssg-firefox-ds-tailoring-2.xml
438
443
  - test/files/tailoring_files/ssg-firefox-ds-tailoring.xml
439
444
  - test/functional/api/v2/compliance/arf_reports_controller_test.rb
445
+ - test/functional/api/v2/compliance/hosts_bulk_actions_controller_test.rb
440
446
  - test/functional/api/v2/compliance/policies_controller_test.rb
441
447
  - test/functional/api/v2/compliance/scap_content_profiles_controller_test.rb
442
448
  - test/functional/api/v2/compliance/scap_contents_controller_test.rb
@@ -1,41 +0,0 @@
1
- // Jest Snapshot v1, https://goo.gl/fbAQLP
2
-
3
- exports[`RuleSeverity should render for Critical severity 1`] = `
4
- <img
5
- alt="Unknown Serverity"
6
- className="severity-img"
7
- src={[Function]}
8
- />
9
- `;
10
-
11
- exports[`RuleSeverity should render for High severity 1`] = `
12
- <img
13
- alt="Unknown Serverity"
14
- className="severity-img"
15
- src={[Function]}
16
- />
17
- `;
18
-
19
- exports[`RuleSeverity should render for Low severity 1`] = `
20
- <img
21
- alt="Unknown Serverity"
22
- className="severity-img"
23
- src={[Function]}
24
- />
25
- `;
26
-
27
- exports[`RuleSeverity should render for Medium severity 1`] = `
28
- <img
29
- alt="Unknown Serverity"
30
- className="severity-img"
31
- src={[Function]}
32
- />
33
- `;
34
-
35
- exports[`RuleSeverity should render for foo severity 1`] = `
36
- <img
37
- alt="Unknown Serverity"
38
- className="severity-img"
39
- src={[Function]}
40
- />
41
- `;