diff --git a/app/controllers/concerns/foreman_openscap/api/v2/hosts_bulk_actions_controller_extensions.rb b/app/controllers/concerns/foreman_openscap/api/v2/hosts_bulk_actions_controller_extensions.rb
new file mode 100644
index 000000000..cae226417
--- /dev/null
+++ b/app/controllers/concerns/foreman_openscap/api/v2/hosts_bulk_actions_controller_extensions.rb
@@ -0,0 +1,91 @@
+module ForemanOpenscap
+ module Api
+ module V2
+ module HostsBulkActionsControllerExtensions
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :find_editable_hosts, only: [:change_openscap_proxy]
+ end
+
+ def change_openscap_proxy
+ openscap_proxy_id = params[:openscap_proxy_id]
+
+ if openscap_proxy_id.blank?
+ return render json: {
+ error: {
+ message: _("No OpenSCAP Proxy selected."),
+ },
+ }, status: :unprocessable_entity
+ end
+
+ smart_proxy = begin
+ ::SmartProxy.find(openscap_proxy_id)
+ rescue ActiveRecord::RecordNotFound
+ nil
+ end
+
+ if smart_proxy.nil?
+ return render json: {
+ error: {
+ message: _("OpenSCAP proxy with id %s not found") % openscap_proxy_id,
+ },
+ }, status: :unprocessable_entity
+ end
+
+ unless smart_proxy.has_feature?('Openscap')
+ return render json: {
+ error: {
+ message: _("The selected proxy does not have the OpenSCAP feature enabled."),
+ },
+ }, status: :unprocessable_entity
+ end
+
+ failed_hosts = []
+ @hosts.each do |host|
+ host.openscap_proxy = smart_proxy
+ failed_hosts << host unless host.save
+ end
+
+ if failed_hosts.empty?
+ process_response(true, {
+ message: _("OpenSCAP Proxy set to %s") % smart_proxy.name,
+ })
+ else
+ total_count = @hosts.count
+ failed_count = failed_hosts.count
+ success_count = total_count - failed_count
+
+ parts = [
+ n_("Failed to assign OpenSCAP Proxy to %{failed} of %{total} host.",
+ "Failed to assign OpenSCAP Proxy to %{failed} of %{total} hosts.",
+ total_count) % { failed: failed_count, total: total_count },
+ ]
+ if success_count > 0
+ parts << n_("Successfully updated %{success} host.",
+ "Successfully updated %{success} hosts.",
+ success_count) % { success: success_count }
+ end
+
+ render_error(:bulk_hosts_error, status: :unprocessable_entity,
+ locals: {
+ message: parts.join(' '),
+ failed_host_ids: failed_hosts.map(&:id),
+ })
+ end
+ end
+
+ private
+
+ def action_permission
+ case params[:action]
+ when 'change_openscap_proxy'
+ 'edit'
+ else
+ super
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/config/routes.rb b/config/routes.rb
index e9f5ca4f5..dee9d38fa 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -103,6 +103,8 @@
end
end
end
+
+ match 'hosts/bulk/change_openscap_proxy', :to => 'hosts_bulk_actions#change_openscap_proxy', :via => [:put]
end
end
end
diff --git a/lib/foreman_openscap/engine.rb b/lib/foreman_openscap/engine.rb
index 7ade8dd68..dc070c240 100644
--- a/lib/foreman_openscap/engine.rb
+++ b/lib/foreman_openscap/engine.rb
@@ -93,7 +93,8 @@ class Engine < ::Rails::Engine
:resource_type => 'ForemanOpenscap::ScapContent'
permission :edit_hosts, { :hosts => %i[openscap_proxy_changed
select_multiple_openscap_proxy
- update_multiple_openscap_proxy] },
+ update_multiple_openscap_proxy],
+ 'api/v2/hosts_bulk_actions' => [:change_openscap_proxy] },
:resource_type => "Host"
permission :view_hosts, { 'api/v2/hosts' => [:policies_enc] }, :resource_type => 'Host'
permission :edit_hostgroups, { :hostgroups => [:openscap_proxy_changed] }, :resource_type => "Hostgroup"
@@ -195,6 +196,7 @@ class Engine < ::Rails::Engine
# Include concerns in this config.to_prepare block
config.to_prepare do
::Api::V2::HostsController.send(:include, ForemanOpenscap::Api::V2::HostsControllerExtensions)
+ ::Api::V2::HostsBulkActionsController.send(:include, ForemanOpenscap::Api::V2::HostsBulkActionsControllerExtensions)
::Host::Managed.send(:include, ForemanOpenscap::OpenscapProxyExtensions)
::Host::Managed.send(:include, ForemanOpenscap::OpenscapProxyCoreExtensions)
::Host::Managed.send(:prepend, ForemanOpenscap::HostExtensions)
diff --git a/test/functional/api/v2/hosts_bulk_actions_controller_test.rb b/test/functional/api/v2/hosts_bulk_actions_controller_test.rb
new file mode 100644
index 000000000..be8e59598
--- /dev/null
+++ b/test/functional/api/v2/hosts_bulk_actions_controller_test.rb
@@ -0,0 +1,127 @@
+require 'test_plugin_helper'
+
+class Api::V2::HostsBulkActionsControllerTest < ActionController::TestCase
+ def setup
+ as_admin do
+ @organization = FactoryBot.create(:organization)
+ @location = FactoryBot.create(:location)
+ @proxy = FactoryBot.create(:openscap_proxy,
+ :organizations => [@organization],
+ :locations => [@location])
+ @host1 = FactoryBot.create(:host, :managed,
+ :organization => @organization,
+ :location => @location)
+ @host2 = FactoryBot.create(:host, :managed,
+ :organization => @organization,
+ :location => @location)
+ @host_ids = [@host1.id, @host2.id]
+ end
+ end
+
+ def valid_bulk_params(host_ids = @host_ids)
+ {
+ :organization_id => @organization.id,
+ :location_id => @location.id,
+ :included => {
+ :ids => host_ids,
+ },
+ :excluded => {
+ :ids => [],
+ },
+ }
+ end
+
+ test "should assign openscap proxy to selected hosts" do
+ put :change_openscap_proxy,
+ params: valid_bulk_params.merge(:openscap_proxy_id => @proxy.id),
+ session: set_session_user
+
+ assert_response :success
+ response = ActiveSupport::JSON.decode(@response.body)
+ assert_match(/OpenSCAP Proxy set to/, response['message'])
+ assert_includes response['message'], @proxy.name
+
+ [@host1, @host2].each do |host|
+ host.reload
+ assert_equal @proxy.id, host.openscap_proxy_id
+ end
+ end
+
+ test "should require openscap_proxy_id" do
+ put :change_openscap_proxy,
+ params: valid_bulk_params,
+ session: set_session_user
+
+ assert_response :unprocessable_entity
+ response = ActiveSupport::JSON.decode(@response.body)
+ assert_match(/No OpenSCAP Proxy selected/, response['error']['message'])
+ end
+
+ test "should return error when proxy is not found" do
+ put :change_openscap_proxy,
+ params: valid_bulk_params.merge(:openscap_proxy_id => 0),
+ session: set_session_user
+
+ assert_response :unprocessable_entity
+ response = ActiveSupport::JSON.decode(@response.body)
+ assert_match(/not found/, response['error']['message'])
+ end
+
+ test "should return error when proxy lacks Openscap feature" do
+ other_proxy = FactoryBot.create(:smart_proxy,
+ :organizations => [@organization],
+ :locations => [@location])
+ openscap_feature = Feature.find_by(:name => 'Openscap')
+ other_proxy.features.delete(openscap_feature) if openscap_feature
+ refute other_proxy.reload.has_feature?('Openscap')
+
+ put :change_openscap_proxy,
+ params: valid_bulk_params.merge(:openscap_proxy_id => other_proxy.id),
+ session: set_session_user
+
+ assert_response :unprocessable_entity
+ response = ActiveSupport::JSON.decode(@response.body)
+ assert_match(/OpenSCAP feature/, response['error']['message'])
+ end
+
+ test "should assign openscap proxy for a single host" do
+ put :change_openscap_proxy,
+ params: valid_bulk_params([@host1.id]).merge(:openscap_proxy_id => @proxy.id),
+ session: set_session_user
+
+ assert_response :success
+ @host1.reload
+ assert_equal @proxy.id, @host1.openscap_proxy_id
+ @host2.reload
+ assert_nil @host2.openscap_proxy_id
+ end
+
+ test "should report failed and successful counts on partial failure" do
+ Host.any_instance.stubs(:save).returns(false).then.returns(true)
+
+ put :change_openscap_proxy,
+ params: valid_bulk_params.merge(:openscap_proxy_id => @proxy.id),
+ session: set_session_user
+
+ assert_response :unprocessable_entity
+ response = ActiveSupport::JSON.decode(@response.body)
+ assert_match(/Failed to assign OpenSCAP Proxy to 1 of 2 hosts/, response['error']['message'])
+ assert_match(/Successfully updated 1 host/, response['error']['message'])
+ assert_equal 1, response['error']['failed_host_ids'].size
+ assert_includes @host_ids, response['error']['failed_host_ids'].first
+ end
+
+ test "should report only failures when all hosts fail" do
+ Host.any_instance.stubs(:save).returns(false)
+
+ put :change_openscap_proxy,
+ params: valid_bulk_params.merge(:openscap_proxy_id => @proxy.id),
+ session: set_session_user
+
+ assert_response :unprocessable_entity
+ response = ActiveSupport::JSON.decode(@response.body)
+ assert_match(/Failed to assign OpenSCAP Proxy to 2 of 2 hosts/, response['error']['message'])
+ refute_match(/Successfully updated/, response['error']['message'])
+ assert_equal @host_ids.sort, response['error']['failed_host_ids'].sort
+ end
+end
diff --git a/webpack/components/HostsIndex/BulkActions/changeOpenscapProxy/BulkChangeOpenscapProxyModal.js b/webpack/components/HostsIndex/BulkActions/changeOpenscapProxy/BulkChangeOpenscapProxyModal.js
new file mode 100644
index 000000000..e464d98a7
--- /dev/null
+++ b/webpack/components/HostsIndex/BulkActions/changeOpenscapProxy/BulkChangeOpenscapProxyModal.js
@@ -0,0 +1,243 @@
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import { useDispatch, useSelector } from 'react-redux';
+import {
+ Modal,
+ Button,
+ Grid,
+ GridItem,
+ Form,
+ FormGroup,
+ Stack,
+ StackItem,
+ Skeleton,
+} from '@patternfly/react-core';
+// eslint-disable-next-line import/no-unresolved
+import { SimpleDropdown } from '@patternfly/react-templates';
+import { foremanUrl } from 'foremanReact/common/helpers';
+import { addToast } from 'foremanReact/components/ToastsList';
+import { APIActions } from 'foremanReact/redux/API';
+import { sprintf, translate as __ } from 'foremanReact/common/I18n';
+import { STATUS } from 'foremanReact/constants';
+import {
+ selectAPIStatus,
+ selectAPIResponse,
+} from 'foremanReact/redux/API/APISelectors';
+import { buildBulkRequestBody } from 'foremanReact/components/HostsIndex/BulkActions/helpers';
+import {
+ BULK_CHANGE_OPENSCAP_PROXY_KEY,
+ HOSTS_API_PATH,
+ HOSTS_API_REQUEST_KEY,
+ OPENSCAP_PROXIES_KEY,
+} from '../../../OpenscapRemediationWizard/constants';
+
+const fetchOpenscapProxies = () =>
+ APIActions.get({
+ key: OPENSCAP_PROXIES_KEY,
+ url: foremanUrl(
+ '/api/smart_proxies?search=feature%3DOpenscap&per_page=all'
+ ),
+ });
+
+const BulkChangeOpenscapProxyModal = ({
+ isOpen,
+ closeModal,
+ selectAllHostsMode,
+ selectedCount,
+ fetchBulkParams,
+ organizationId,
+ locationId,
+}) => {
+ const dispatch = useDispatch();
+ const [proxyId, setProxyId] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ useEffect(() => {
+ if (isOpen) {
+ dispatch(fetchOpenscapProxies());
+ } else {
+ setIsSubmitting(false);
+ }
+ }, [dispatch, isOpen]);
+
+ const proxies = useSelector(state =>
+ selectAPIResponse(state, OPENSCAP_PROXIES_KEY)
+ );
+ const proxyStatus = useSelector(state =>
+ selectAPIStatus(state, OPENSCAP_PROXIES_KEY)
+ );
+
+ const handleModalClose = () => {
+ setProxyId('');
+ setIsSubmitting(false);
+ closeModal();
+ };
+
+ const handleSuccess = response => {
+ dispatch(
+ APIActions.get({
+ key: HOSTS_API_REQUEST_KEY,
+ url: foremanUrl(HOSTS_API_PATH),
+ })
+ );
+ dispatch(
+ addToast({
+ type: 'success',
+ message:
{response?.data?.message}
,
+ })
+ );
+ handleModalClose();
+ };
+
+ const handleError = error => {
+ dispatch(
+ addToast({
+ type: 'error',
+ message: {error?.response?.data?.error?.message}
,
+ })
+ );
+ setIsSubmitting(false);
+ handleModalClose();
+ };
+
+ const handleConfirm = () => {
+ const requestBody = buildBulkRequestBody({
+ fetchBulkParams,
+ organizationId,
+ locationId,
+ openscap_proxy_id: proxyId,
+ });
+
+ setIsSubmitting(true);
+ dispatch(
+ APIActions.put({
+ key: BULK_CHANGE_OPENSCAP_PROXY_KEY,
+ url: foremanUrl('/api/v2/hosts/bulk/change_openscap_proxy'),
+ handleSuccess,
+ handleError,
+ params: requestBody,
+ })
+ );
+ };
+
+ const descriptionText = selectAllHostsMode ? (
+ <>
+ {__('Assign OpenSCAP Proxy for ')}
+ {__('ALL selected hosts.')}
+
+ {__('This will change previous proxy assignments on the selected hosts.')}
+ >
+ ) : (
+ <>
+ {__('Assign OpenSCAP Proxy for ')}
+ {sprintf(__('%s selected hosts.'), selectedCount)}
+
+ {__('This will change previous proxy assignments on the selected hosts.')}
+ >
+ );
+
+ const getProxyLabel = id => {
+ const proxy = proxies?.results?.find(
+ p => p.id.toString() === id.toString()
+ );
+ return proxy?.name || id;
+ };
+
+ const proxyItems =
+ proxies?.results?.map(proxy => ({
+ value: proxy.id.toString(),
+ content: proxy.name,
+ onClick: () => setProxyId(proxy.id.toString()),
+ })) || [];
+
+ const modalActions = [
+ ,
+ ,
+ ];
+
+ return (
+
+
+ {descriptionText}
+ {proxyStatus === STATUS.RESOLVED && proxies?.results?.length > 0 && (
+
+
+
+
+
+
+
+ )}
+ {proxyStatus === STATUS.RESOLVED &&
+ (!proxies?.results || proxies.results.length === 0) &&
+ __(
+ 'No OpenSCAP Proxies available. Please configure a Smart Proxy with the OpenSCAP feature.'
+ )}
+ {proxyStatus === STATUS.PENDING && (
+
+ )}
+
+
+ );
+};
+
+BulkChangeOpenscapProxyModal.propTypes = {
+ isOpen: PropTypes.bool,
+ closeModal: PropTypes.func,
+ fetchBulkParams: PropTypes.func.isRequired,
+ selectedCount: PropTypes.number.isRequired,
+ selectAllHostsMode: PropTypes.bool.isRequired,
+ organizationId: PropTypes.number,
+ locationId: PropTypes.number,
+};
+
+BulkChangeOpenscapProxyModal.defaultProps = {
+ isOpen: false,
+ closeModal: () => {},
+ organizationId: undefined,
+ locationId: undefined,
+};
+
+export default BulkChangeOpenscapProxyModal;
diff --git a/webpack/components/HostsIndex/BulkActions/changeOpenscapProxy/__tests__/BulkChangeOpenscapProxyModal.test.js b/webpack/components/HostsIndex/BulkActions/changeOpenscapProxy/__tests__/BulkChangeOpenscapProxyModal.test.js
new file mode 100644
index 000000000..25c5b8b46
--- /dev/null
+++ b/webpack/components/HostsIndex/BulkActions/changeOpenscapProxy/__tests__/BulkChangeOpenscapProxyModal.test.js
@@ -0,0 +1,160 @@
+import React from 'react';
+import { screen, fireEvent, waitFor, within } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { rtlHelpers, initMockStore } from 'foremanReact/common/testHelpers';
+import { STATUS } from 'foremanReact/constants';
+import { APIActions } from 'foremanReact/redux/API';
+import { OPENSCAP_PROXIES_KEY } from '../../../../OpenscapRemediationWizard/constants';
+import BulkChangeOpenscapProxyModal from '../BulkChangeOpenscapProxyModal';
+
+const { renderWithStore } = rtlHelpers;
+
+jest.mock('foremanReact/common/I18n');
+
+jest.spyOn(APIActions, 'get');
+jest.spyOn(APIActions, 'put');
+
+const proxies = {
+ results: [
+ { id: 1, name: 'openscap-proxy-1.example.com' },
+ { id: 2, name: 'openscap-proxy-2.example.com' },
+ ],
+};
+
+const defaultProps = {
+ selectedCount: 3,
+ selectAllHostsMode: false,
+ fetchBulkParams: jest.fn(() => 'id ^ (1,2,3)'),
+ isOpen: true,
+ closeModal: jest.fn(),
+ organizationId: 1,
+ locationId: 2,
+};
+
+const proxiesResolvedState = {
+ API: {
+ [OPENSCAP_PROXIES_KEY]: {
+ status: STATUS.RESOLVED,
+ response: proxies,
+ },
+ },
+};
+
+const noProxiesState = {
+ API: {
+ [OPENSCAP_PROXIES_KEY]: {
+ status: STATUS.RESOLVED,
+ response: { results: [] },
+ },
+ },
+};
+
+const renderModal = (props = {}, initialState = proxiesResolvedState) =>
+ renderWithStore(
+ ,
+ initialState
+ );
+
+describe('BulkChangeOpenscapProxyModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // renderWithStore lodash-merges into shared initMockStore; clear prior API fixtures
+ delete initMockStore.API[OPENSCAP_PROXIES_KEY];
+ APIActions.get.mockImplementation(payload => ({
+ type: 'TEST_API_GET',
+ payload,
+ }));
+ APIActions.put.mockImplementation(payload => ({
+ type: 'TEST_API_PUT',
+ payload,
+ }));
+ });
+
+ it('renders modal title and description with selected host count', () => {
+ renderModal();
+ expect(screen.getByText('Assign OpenSCAP Proxy')).toBeInTheDocument();
+ expect(screen.getByText('3 selected hosts.')).toBeInTheDocument();
+ });
+
+ it('renders all-hosts mode description', () => {
+ renderModal({ selectAllHostsMode: true });
+ expect(screen.getByText('ALL selected hosts.')).toBeInTheDocument();
+ });
+
+ it('disables Assign until a proxy is selected', () => {
+ renderModal();
+ expect(screen.getByRole('button', { name: 'Assign' })).toBeDisabled();
+ });
+
+ it('lists OpenSCAP proxies in the dropdown', async () => {
+ renderModal();
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Select OpenSCAP Proxy' })
+ );
+
+ const menu = await screen.findByRole('menu');
+ expect(
+ within(menu).getByRole('menuitem', {
+ name: 'openscap-proxy-1.example.com',
+ })
+ ).toBeInTheDocument();
+ expect(
+ within(menu).getByRole('menuitem', {
+ name: 'openscap-proxy-2.example.com',
+ })
+ ).toBeInTheDocument();
+ });
+
+ it('enables Assign and dispatches bulk PUT after selecting a proxy', async () => {
+ renderModal();
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Select OpenSCAP Proxy' })
+ );
+
+ const menu = await screen.findByRole('menu');
+ fireEvent.click(
+ within(menu).getByRole('menuitem', {
+ name: 'openscap-proxy-1.example.com',
+ })
+ );
+
+ const assignBtn = screen.getByRole('button', { name: 'Assign' });
+ await waitFor(() => {
+ expect(assignBtn).not.toBeDisabled();
+ });
+
+ fireEvent.click(assignBtn);
+ expect(APIActions.put).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: expect.stringContaining('/hosts/bulk/change_openscap_proxy'),
+ params: expect.objectContaining({
+ included: { search: 'id ^ (1,2,3)' },
+ organization_id: 1,
+ location_id: 2,
+ openscap_proxy_id: '1',
+ }),
+ })
+ );
+ });
+
+ it('calls closeModal on Cancel', () => {
+ const closeModal = jest.fn();
+ renderModal({ closeModal });
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ expect(closeModal).toHaveBeenCalled();
+ });
+
+ it('shows empty state when no proxies are available', () => {
+ renderModal({}, noProxiesState);
+ expect(
+ screen.getByText(
+ 'No OpenSCAP Proxies available. Please configure a Smart Proxy with the OpenSCAP feature.'
+ )
+ ).toBeInTheDocument();
+ });
+
+ it('does not render when isOpen is false', () => {
+ renderModal({ isOpen: false });
+ expect(screen.queryByText('Assign OpenSCAP Proxy')).not.toBeInTheDocument();
+ });
+});
diff --git a/webpack/components/HostsIndex/ChangeOpenscapProxyAction.js b/webpack/components/HostsIndex/ChangeOpenscapProxyAction.js
new file mode 100644
index 000000000..2c2d45305
--- /dev/null
+++ b/webpack/components/HostsIndex/ChangeOpenscapProxyAction.js
@@ -0,0 +1,63 @@
+import React, { useContext } from 'react';
+import PropTypes from 'prop-types';
+import { MenuItem } from '@patternfly/react-core';
+import { translate as __ } from 'foremanReact/common/I18n';
+import {
+ openBulkModal,
+ useBulkModalOpen,
+} from 'foremanReact/common/BulkModalStateHelper';
+import { ForemanActionsBarContext } from 'foremanReact/components/HostDetails/ActionsBar';
+import BulkChangeOpenscapProxyModal from './BulkActions/changeOpenscapProxy/BulkChangeOpenscapProxyModal';
+import { CHANGE_OPENSCAP_MODAL_ID } from '../OpenscapRemediationWizard/constants';
+
+export const ChangeOpenscapProxyMenuItem = ({ selectedCount }) => {
+ const openModal = () => openBulkModal(CHANGE_OPENSCAP_MODAL_ID, true);
+
+ return (
+
+ );
+};
+
+ChangeOpenscapProxyMenuItem.propTypes = {
+ selectedCount: PropTypes.number,
+};
+
+ChangeOpenscapProxyMenuItem.defaultProps = {
+ selectedCount: 0,
+};
+
+const BulkChangeOpenscapProxyModalScene = () => {
+ const {
+ selectAllHostsMode = false,
+ selectedCount = 0,
+ fetchBulkParams,
+ organizationId,
+ locationId,
+ } = useContext(ForemanActionsBarContext) || {};
+
+ const { isOpen, close: closeModal } = useBulkModalOpen(
+ CHANGE_OPENSCAP_MODAL_ID
+ );
+
+ return (
+
+ );
+};
+
+export default BulkChangeOpenscapProxyModalScene;
diff --git a/webpack/components/HostsIndex/__tests__/ChangeOpenscapProxyAction.test.js b/webpack/components/HostsIndex/__tests__/ChangeOpenscapProxyAction.test.js
new file mode 100644
index 000000000..9890d3704
--- /dev/null
+++ b/webpack/components/HostsIndex/__tests__/ChangeOpenscapProxyAction.test.js
@@ -0,0 +1,174 @@
+import React from 'react';
+import { screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { Menu, MenuContent, MenuList } from '@patternfly/react-core';
+import { rtlHelpers, initMockStore } from 'foremanReact/common/testHelpers';
+import { openBulkModal } from 'foremanReact/common/BulkModalStateHelper';
+import { ForemanActionsBarContext } from 'foremanReact/components/HostDetails/ActionsBar';
+import { STATUS } from 'foremanReact/constants';
+import { APIActions } from 'foremanReact/redux/API';
+import {
+ CHANGE_OPENSCAP_MODAL_ID,
+ OPENSCAP_PROXIES_KEY,
+} from '../../OpenscapRemediationWizard/constants';
+import BulkChangeOpenscapProxyModalScene, {
+ ChangeOpenscapProxyMenuItem,
+} from '../ChangeOpenscapProxyAction';
+
+const { renderWithStore } = rtlHelpers;
+
+jest.mock('foremanReact/common/I18n');
+
+jest.spyOn(APIActions, 'get');
+jest.spyOn(APIActions, 'put');
+
+const proxiesResolvedState = {
+ API: {
+ [OPENSCAP_PROXIES_KEY]: {
+ status: STATUS.RESOLVED,
+ response: {
+ results: [
+ { id: 1, name: 'openscap-proxy-1.example.com' },
+ { id: 2, name: 'openscap-proxy-2.example.com' },
+ ],
+ },
+ },
+ },
+};
+
+const actionsBarValue = {
+ selectAllHostsMode: false,
+ selectedCount: 3,
+ fetchBulkParams: jest.fn(() => 'id ^ (1,2,3)'),
+ organizationId: 1,
+ locationId: 2,
+};
+
+const renderMenuItem = (props = {}) =>
+ renderWithStore(
+ // eslint-disable-next-line @theforeman/rules/require-ouiaid
+
+ );
+
+const renderScene = (contextValue = {}, initialState = proxiesResolvedState) =>
+ renderWithStore(
+
+
+ ,
+ initialState
+ );
+
+const renderMenuItemWithScene = (
+ menuProps = {},
+ contextValue = {},
+ initialState = proxiesResolvedState
+) =>
+ renderWithStore(
+
+ {/* eslint-disable-next-line @theforeman/rules/require-ouiaid */}
+
+
+ ,
+ initialState
+ );
+
+describe('ChangeOpenscapProxyMenuItem', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ delete initMockStore.API[OPENSCAP_PROXIES_KEY];
+ openBulkModal(CHANGE_OPENSCAP_MODAL_ID, false);
+ APIActions.get.mockImplementation(payload => ({
+ type: 'TEST_API_GET',
+ payload,
+ }));
+ APIActions.put.mockImplementation(payload => ({
+ type: 'TEST_API_PUT',
+ payload,
+ }));
+ });
+
+ it('renders the OpenSCAP Proxy menu item', () => {
+ renderMenuItem();
+ expect(screen.getByText('OpenSCAP Proxy')).toBeInTheDocument();
+ });
+
+ it('is disabled when no hosts are selected', () => {
+ renderMenuItem({ selectedCount: 0 });
+ expect(
+ screen.getByRole('menuitem', { name: 'OpenSCAP Proxy' })
+ ).toBeDisabled();
+ });
+
+ it('opens the bulk modal when clicked', () => {
+ renderMenuItemWithScene({ selectedCount: 2 });
+
+ expect(screen.queryByText('Assign OpenSCAP Proxy')).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByText('OpenSCAP Proxy'));
+
+ expect(screen.getByText('Assign OpenSCAP Proxy')).toBeInTheDocument();
+ });
+});
+
+describe('BulkChangeOpenscapProxyModalScene', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ delete initMockStore.API[OPENSCAP_PROXIES_KEY];
+ openBulkModal(CHANGE_OPENSCAP_MODAL_ID, false);
+ APIActions.get.mockImplementation(payload => ({
+ type: 'TEST_API_GET',
+ payload,
+ }));
+ APIActions.put.mockImplementation(payload => ({
+ type: 'TEST_API_PUT',
+ payload,
+ }));
+ });
+
+ it('does not render the modal when closed', () => {
+ renderScene();
+ expect(screen.queryByText('Assign OpenSCAP Proxy')).not.toBeInTheDocument();
+ });
+
+ it('passes actions-bar context props when the modal is open', () => {
+ openBulkModal(CHANGE_OPENSCAP_MODAL_ID, true);
+ renderScene({
+ selectAllHostsMode: true,
+ selectedCount: 5,
+ organizationId: 10,
+ locationId: 20,
+ });
+
+ expect(screen.getByText('Assign OpenSCAP Proxy')).toBeInTheDocument();
+ expect(screen.getByText('ALL selected hosts.')).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Select OpenSCAP Proxy' })
+ ).toBeInTheDocument();
+ });
+
+ it('closes the modal via Cancel', () => {
+ openBulkModal(CHANGE_OPENSCAP_MODAL_ID, true);
+ renderScene();
+
+ expect(screen.getByText('Assign OpenSCAP Proxy')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ expect(screen.queryByText('Assign OpenSCAP Proxy')).not.toBeInTheDocument();
+ });
+});
diff --git a/webpack/components/OpenscapRemediationWizard/constants.js b/webpack/components/OpenscapRemediationWizard/constants.js
index b8f745bde..7e6f34d33 100644
--- a/webpack/components/OpenscapRemediationWizard/constants.js
+++ b/webpack/components/OpenscapRemediationWizard/constants.js
@@ -1,7 +1,5 @@
import { translate as __ } from 'foremanReact/common/I18n';
-export const OPENSCAP_REMEDIATION_MODAL_ID = 'openscapRemediationModal';
-export const HOSTS_PATH = '/hosts';
export const FAIL_RULE_SEARCH = 'fails_xccdf_rule';
export const HOSTS_API_PATH = '/api/hosts';
@@ -21,3 +19,8 @@ export const WIZARD_TITLES = {
reviewRemediation: __('Review remediation'),
finish: __('Done'),
};
+
+export const BULK_CHANGE_OPENSCAP_PROXY_KEY = 'BULK_CHANGE_OPENSCAP_PROXY';
+export const OPENSCAP_PROXIES_KEY = 'OPENSCAP_PROXIES_KEY';
+
+export const CHANGE_OPENSCAP_MODAL_ID = 'BULK_CHANGE_OPENSCAP_PROXY_MODAL';
diff --git a/webpack/global_index.js b/webpack/global_index.js
index d62e51fa3..35979f093 100644
--- a/webpack/global_index.js
+++ b/webpack/global_index.js
@@ -1,10 +1,31 @@
import React from 'react';
import { addGlobalFill } from 'foremanReact/components/common/Fill/GlobalFill';
import HostKebabItems from './components/HostExtentions/HostKebabItems';
+import BulkChangeOpenscapProxyModalScene, {
+ ChangeOpenscapProxyMenuItem,
+} from './components/HostsIndex/ChangeOpenscapProxyAction';
+
+const HOST_DETAILS_KEBAB_WEIGHT = 400;
+const HOST_ASSOCIATIONS_WEIGHT = 1212;
+const BULK_MODAL_WEIGHT = 100;
addGlobalFill(
'host-details-kebab',
`openscap-kebab-items`,
,
- 400
+ HOST_DETAILS_KEBAB_WEIGHT
+);
+
+addGlobalFill(
+ '_host-associations',
+ 'openscap-change-proxy-menu-item',
+ ,
+ HOST_ASSOCIATIONS_WEIGHT
+);
+
+addGlobalFill(
+ '_all-hosts-modals',
+ 'BulkChangeOpenscapProxyModal',
+ ,
+ BULK_MODAL_WEIGHT
);