foreman_openscap 13.0.0 → 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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/api/v2/compliance/hosts_bulk_actions_controller.rb +89 -0
  3. data/config/routes.rb +2 -0
  4. data/lib/foreman_openscap/engine.rb +2 -1
  5. data/lib/foreman_openscap/version.rb +1 -1
  6. data/test/functional/api/v2/compliance/hosts_bulk_actions_controller_test.rb +132 -0
  7. data/webpack/components/HostsIndex/BulkActions/changeOpenscapProxy/BulkChangeOpenscapProxyModal.js +227 -0
  8. data/webpack/components/HostsIndex/BulkActions/changeOpenscapProxy/__tests__/BulkChangeOpenscapProxyModal.test.js +198 -0
  9. data/webpack/components/HostsIndex/ChangeOpenscapProxyAction.js +65 -0
  10. data/webpack/components/HostsIndex/__tests__/ChangeOpenscapProxyAction.test.js +172 -0
  11. data/webpack/components/LineChart/LineChartHelpers.js +23 -7
  12. data/webpack/components/OpenscapRemediationWizard/constants.js +8 -2
  13. data/webpack/components/OpenscapRemediationWizard/steps/Finish.js +2 -2
  14. data/webpack/components/OpenscapRemediationWizard/steps/ReviewRemediation.js +10 -2
  15. data/webpack/components/OpenscapRemediationWizard/steps/SnippetSelect.js +3 -1
  16. data/webpack/components/RuleSeverity/RuleSeverity.test.js +35 -8
  17. data/webpack/components/RuleSeverity/index.js +5 -5
  18. data/webpack/global_index.js +23 -1
  19. data/webpack/test_setup.js +1 -0
  20. metadata +10 -19
  21. data/webpack/components/ConfirmModal.js +0 -66
  22. data/webpack/components/ConfirmModal.scss +0 -3
  23. data/webpack/components/IndexLayout.js +0 -44
  24. data/webpack/components/IndexTable/IndexTableHelper.js +0 -6
  25. data/webpack/components/IndexTable/index.js +0 -73
  26. data/webpack/components/LinkButton.js +0 -42
  27. data/webpack/components/RuleSeverity/__snapshots__/RuleSeverity.test.js.snap +0 -41
  28. data/webpack/components/withDeleteModal.js +0 -51
  29. data/webpack/components/withLoading.js +0 -107
  30. data/webpack/helpers/commonHelper.js +0 -1
  31. data/webpack/helpers/globalIdHelper.js +0 -15
  32. data/webpack/helpers/mutationHelper.js +0 -68
  33. data/webpack/helpers/pageParamsHelper.js +0 -31
  34. data/webpack/helpers/permissionsHelper.js +0 -42
  35. data/webpack/helpers/tableHelper.js +0 -9
  36. data/webpack/helpers/toastHelper.js +0 -3
  37. data/webpack/testHelper.js +0 -127
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f58e733e3dd18596f3c7e16c4015103df7729a629577735ccb67ed7912172c3f
4
- data.tar.gz: c87d597fcae523beb2f8607bc4a88bb77ee2508ce127b19761f6030d6eefd3e5
3
+ metadata.gz: 18cf4b07fd89183f5ced6a907f35a88be64e9a19461dfdfc68511d826e42797e
4
+ data.tar.gz: f581fc51af310ca96c388121914040d7885d2b81d42c14e1982a8efa6501c336
5
5
  SHA512:
6
- metadata.gz: 5a7eb9acc11cac66fc65081c49bb6bfb4cbe58fbc5accadf5896ba19af130d9237a8825215c6a866d68252dc51f5cdd28516b072494f8c969960b47e63221e1f
7
- data.tar.gz: c68a53b00952b47560b977ebb6a661c24a3ab7a064f8ddfc05ba4a1c26e97882796db01556bd57fa705a6637a1df4073ff083716ff7909a9c11d1a333a9ac735
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.0".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
+ });