diff --git a/app/controllers/api/v2/hosts_bulk_actions_controller.rb b/app/controllers/api/v2/hosts_bulk_actions_controller.rb index 0c7bce1830..d2faba567d 100644 --- a/app/controllers/api/v2/hosts_bulk_actions_controller.rb +++ b/app/controllers/api/v2/hosts_bulk_actions_controller.rb @@ -4,11 +4,17 @@ class HostsBulkActionsController < V2::BaseController include Api::Version2 include Api::V2::BulkHostsExtension + before_action :validate_scope_hash!, + :only => [:bulk_destroy, :build, :reassign_hostgroup, :change_owner, + :disassociate, :change_power_state, :assign_organization, + :assign_location] before_action :find_deletable_hosts, :only => [:bulk_destroy] before_action :find_editable_hosts, :only => [:build, :reassign_hostgroup, :change_owner, :disassociate, :change_power_state, :manage_notifications] before_action :validate_power_action, :only => [:change_power_state] def_param_group :bulk_host_ids do + param :scope_hash, String, :required => false, :desc => N_("Fingerprint of the visible host scope when the bulk action was initiated") + param :skip_scope_hash, :bool, :required => false, :desc => N_("Skip validation of the visible host scope fingerprint for direct API usage") param :included, Hash, :desc => N_("Hosts to include in the action"), :required => true, :action_aware => true do param :search, String, :required => false, :desc => N_("Search string describing which hosts to perform the action on") param :ids, Array, :required => false, :desc => N_("List of host ids to perform the action on") @@ -23,7 +29,12 @@ class HostsBulkActionsController < V2::BaseController api :DELETE, "/hosts/bulk/", N_("Delete multiple hosts") param_group :bulk_host_ids def bulk_destroy - process_response @hosts.destroy_all + deleted_hosts_count = @hosts.count + destroy_result = @hosts.destroy_all + + process_response(destroy_result, { + :message => n_("%s host deleted", "%s hosts deleted", deleted_hosts_count) % deleted_hosts_count, + }) end api :PUT, "/hosts/bulk/build", N_("Build") @@ -209,6 +220,48 @@ def action_permission private + def validate_scope_hash! + return if skip_scope_hash? + + if scope_hash_param.blank? + Rails.logger.debug do + "Bulk scope hash missing for #{self.class}##{action_name} " \ + "(user_id=#{User.current&.id}, organization_id=#{Organization.current&.id}, " \ + "location_id=#{Location.current&.id})" + end + + render_error(:custom_error, :status => :conflict, :locals => { + :message => _('The host scope changed or is missing integrity data. Refresh and retry the bulk action.'), + }) + return + end + + current_scope_hash = ::Hosts::BulkScopeHash.for_current_viewer + return if ActiveSupport::SecurityUtils.secure_compare(scope_hash_param, current_scope_hash) + + Rails.logger.debug do + "Bulk scope hash mismatch for #{self.class}##{action_name} " \ + "(user_id=#{User.current&.id}, organization_id=#{Organization.current&.id}, " \ + "location_id=#{Location.current&.id}, received_scope_hash=#{scope_hash_param.inspect}, " \ + "current_scope_hash=#{current_scope_hash.inspect})" + end + + render_error(:custom_error, :status => :conflict, :locals => { + :message => _('The host scope changed or the organization/location context was modified in another tab. Refresh and retry the bulk action.'), + }) + end + + def scope_hash_param + params[:scope_hash].presence || params.dig(:hosts_bulk_action, :scope_hash).presence + end + + def skip_scope_hash? + Foreman::Cast.to_bool( + params[:skip_scope_hash].presence || + params.dig(:hosts_bulk_action, :skip_scope_hash).presence + ) + end + def find_deletable_hosts find_bulk_hosts(:destroy_hosts, included: params) end diff --git a/app/controllers/api/v2/hosts_controller.rb b/app/controllers/api/v2/hosts_controller.rb index e3071164b6..a5becc3c4d 100644 --- a/app/controllers/api/v2/hosts_controller.rb +++ b/app/controllers/api/v2/hosts_controller.rb @@ -12,7 +12,7 @@ class HostsController < V2::BaseController include HostsControllerExtension before_action :find_optional_nested_object, :except => [:facts] - before_action :find_resource, :except => [:index, :create, :facts] + before_action :find_resource, :except => [:index, :create, :facts, :bulk_scope_hash] check_permissions_for %w{power boot} before_action :process_parameter_attributes, :only => %w{update} @@ -26,6 +26,7 @@ class HostsController < V2::BaseController end api :GET, "/hosts/", N_("List all hosts") + api :GET, "/hosts/bulk_scope_hash", N_("Get hash of the current visible host scope") api :GET, "/hostgroups/:hostgroup_id/hosts", N_("List all hosts for a host group") api :GET, "/locations/:location_id/hosts", N_("List hosts per location") api :GET, "/organizations/:organization_id/hosts", N_("List hosts per organization") @@ -60,6 +61,10 @@ def index end end + def bulk_scope_hash + render :json => { :scope_hash => ::Hosts::BulkScopeHash.for_current_viewer } + end + api :GET, "/hosts/:id/", N_("Show a host") param :id, :identifier_dottable, :required => true param :show_hidden_parameters, :bool, :desc => N_("Display hidden parameter values") @@ -377,6 +382,8 @@ def interface_attributes(params) def action_permission case params[:action] + when 'bulk_scope_hash' + :view when 'power_status' :power when 'power' diff --git a/app/services/hosts/bulk_scope_hash.rb b/app/services/hosts/bulk_scope_hash.rb new file mode 100644 index 0000000000..ffbb337ead --- /dev/null +++ b/app/services/hosts/bulk_scope_hash.rb @@ -0,0 +1,34 @@ +require 'digest' + +module Hosts + class BulkScopeHash + def self.for_current_viewer + new( + user: User.current, + organization: Organization.current, + location: Location.current + ).to_s + end + + def initialize(user:, organization:, location:) + @user = user + @organization = organization + @location = location + end + + def to_s + digest = Digest::SHA256.new + digest << "user:#{@user&.id}|org:#{@organization&.id}|loc:#{@location&.id}|" + + hosts_scope.pluck(:id).each { |id| digest << "#{id}," } + + digest.hexdigest + end + + private + + def hosts_scope + ::Host::Managed.authorized(:view_hosts).reselect(:id).reorder(:id) + end + end +end diff --git a/config/initializers/f_foreman_permissions.rb b/config/initializers/f_foreman_permissions.rb index bb4eb07b31..2c473a2faf 100644 --- a/config/initializers/f_foreman_permissions.rb +++ b/config/initializers/f_foreman_permissions.rb @@ -234,7 +234,7 @@ :templates, :overview, :nics, :get_power_state, :preview_host_collection, :welcome, :statuses], :dashboard => [:OutOfSync, :errors, :active], :unattended => [:host_template, :hostgroup_template], - :"api/v2/hosts" => [:index, :show, :get_status, :vm_compute_attributes, :template, :templates, :enc, :inherited_parameters], + :"api/v2/hosts" => [:index, :show, :get_status, :vm_compute_attributes, :template, :templates, :enc, :inherited_parameters, :bulk_scope_hash], :"api/v2/interfaces" => [:index, :show], :"api/v2/host_statuses" => [:index], :locations => [:mismatches], diff --git a/config/routes/api/v2.rb b/config/routes/api/v2.rb index 519d065b37..712168b480 100644 --- a/config/routes/api/v2.rb +++ b/config/routes/api/v2.rb @@ -4,6 +4,7 @@ # new v2 routes that point to v2 scope "(:apiv)", :module => :v2, :defaults => {:apiv => 'v2'}, :apiv => /v2/, :constraints => ApiConstraints.new(:version => 2, :default => true) do match 'hosts/bulk', :to => 'hosts_bulk_actions#bulk_destroy', :via => [:delete] + get 'hosts/bulk_scope_hash', :to => 'hosts#bulk_scope_hash' put 'hosts/bulk/assign_organization', :to => 'hosts_bulk_actions#assign_organization' put 'hosts/bulk/assign_location', :to => 'hosts_bulk_actions#assign_location' match 'hosts/bulk/build', :to => 'hosts_bulk_actions#build', :via => [:put] diff --git a/test/controllers/api/v2/hosts_bulk_actions_controller_test.rb b/test/controllers/api/v2/hosts_bulk_actions_controller_test.rb index f03af11fd5..68b8e9329f 100644 --- a/test/controllers/api/v2/hosts_bulk_actions_controller_test.rb +++ b/test/controllers/api/v2/hosts_bulk_actions_controller_test.rb @@ -12,6 +12,7 @@ def setup @usergroup = FactoryBot.create(:usergroup) @host_ids = [@host1.id, @host2.id, @host3.id] end + ::Hosts::BulkScopeHash.stubs(:for_current_viewer).returns(valid_scope_hash) end def valid_bulk_params(host_ids = @host_ids) @@ -24,6 +25,7 @@ def valid_bulk_params(host_ids = @host_ids) :excluded => { :ids => [], }, + :scope_hash => valid_scope_hash, } end @@ -31,6 +33,14 @@ def valid_power_params(host_ids = @host_ids, action = 'start') valid_bulk_params(host_ids).merge(:power => action) end + def valid_bulk_destroy_params(host_ids = @host_ids) + { + :organization_id => @organization.id, + :ids => host_ids, + :scope_hash => valid_scope_hash, + } + end + test "should change owner with user id" do put :change_owner, params: valid_bulk_params.merge(:owner_id => @user.id_and_type) @@ -115,6 +125,7 @@ def valid_power_params(host_ids = @host_ids, action = 'start') put :change_owner, params: { :organization_id => @organization.id, :location_id => @location.id, + :scope_hash => valid_scope_hash, :included => { :search => 'name ~ *', }, @@ -258,8 +269,184 @@ def valid_power_params(host_ids = @host_ids, action = 'start') end end + context "bulk_destroy" do + test "successfully deletes all selected hosts" do + assert_difference('Host.count', -@host_ids.size) do + delete :bulk_destroy, params: valid_bulk_destroy_params + end + + assert_response :success + end + end + + context "build" do + test "successfully builds all selected hosts" do + bulk_manager = mock('BulkHostsManager') + BulkHostsManager.expects(:new).with(hosts: anything).returns(bulk_manager) + bulk_manager.expects(:build).with(reboot: false).returns([]) + + put :build, params: valid_bulk_params + + assert_response :success + body = ActiveSupport::JSON.decode(@response.body) + assert_match(/Built 3 hosts/, body['message']) + end + end + + context "disassociate" do + test "successfully disassociates selected hosts" do + bulk_manager = mock('BulkHostsManager') + BulkHostsManager.expects(:new).with(hosts: anything).returns(bulk_manager) + bulk_manager.expects(:disassociate) + + put :disassociate, params: valid_bulk_params + + assert_response :success + body = ActiveSupport::JSON.decode(@response.body) + assert_match(/Updated hosts: Disassociated from compute resource/, body['message']) + end + end + + context "scope_hash validation" do + test "allows bulk destroy without scope hash when skip_scope_hash is true" do + assert_difference('Host.count', -@host_ids.size) do + delete :bulk_destroy, + params: valid_bulk_destroy_params.except(:scope_hash).merge(:skip_scope_hash => true) + end + + assert_response :success + end + + test "rejects bulk destroy when scope hash is missing" do + assert_scope_hash_conflict( + :delete, + :bulk_destroy, + valid_bulk_destroy_params.except(:scope_hash) + ) + end + + test "rejects bulk destroy when scope hash mismatches" do + assert_scope_hash_conflict( + :delete, + :bulk_destroy, + valid_bulk_destroy_params.merge(:scope_hash => 'stale-scope-hash') + ) + end + + test "rejects build when scope hash is missing" do + assert_scope_hash_conflict(:put, :build, valid_bulk_params.except(:scope_hash)) + end + + test "allows build without scope hash when skip_scope_hash is true" do + bulk_manager = mock('BulkHostsManager') + BulkHostsManager.expects(:new).with(hosts: anything).returns(bulk_manager) + bulk_manager.expects(:build).with(reboot: false).returns([]) + + put :build, + params: valid_bulk_params.except(:scope_hash).merge(:skip_scope_hash => true) + + assert_response :success + end + + test "rejects build when scope hash mismatches" do + assert_scope_hash_conflict(:put, :build, valid_bulk_params.merge(:scope_hash => 'stale-scope-hash')) + end + + test "rejects hostgroup reassignment when scope hash is missing" do + assert_scope_hash_conflict( + :put, + :reassign_hostgroup, + valid_bulk_params.except(:scope_hash).merge(:hostgroup_id => 1) + ) + end + + test "rejects hostgroup reassignment when scope hash mismatches" do + assert_scope_hash_conflict( + :put, + :reassign_hostgroup, + valid_bulk_params.merge(:hostgroup_id => 1, :scope_hash => 'stale-scope-hash') + ) + end + + test "rejects owner change when scope hash is missing" do + assert_scope_hash_conflict( + :put, + :change_owner, + valid_bulk_params.except(:scope_hash).merge(:owner_id => @user.id_and_type) + ) + end + + test "rejects owner change when scope hash mismatches" do + assert_scope_hash_conflict( + :put, + :change_owner, + valid_bulk_params.merge(:owner_id => @user.id_and_type, :scope_hash => 'stale-scope-hash') + ) + end + + test "rejects disassociate when scope hash is missing" do + assert_scope_hash_conflict(:put, :disassociate, valid_bulk_params.except(:scope_hash)) + end + + test "rejects disassociate when scope hash mismatches" do + assert_scope_hash_conflict(:put, :disassociate, valid_bulk_params.merge(:scope_hash => 'stale-scope-hash')) + end + + test "rejects organization assignment when scope hash is missing" do + assert_scope_hash_conflict( + :put, + :assign_organization, + valid_bulk_params.except(:scope_hash).merge(:id => @organization.id, :mismatch_setting => true) + ) + end + + test "rejects organization assignment when scope hash mismatches" do + assert_scope_hash_conflict( + :put, + :assign_organization, + valid_bulk_params.merge(:id => @organization.id, :mismatch_setting => true, :scope_hash => 'stale-scope-hash') + ) + end + + test "rejects location assignment when scope hash is missing" do + assert_scope_hash_conflict( + :put, + :assign_location, + valid_bulk_params.except(:scope_hash).merge(:id => @location.id, :mismatch_setting => true) + ) + end + + test "rejects location assignment when scope hash mismatches" do + assert_scope_hash_conflict( + :put, + :assign_location, + valid_bulk_params.merge(:id => @location.id, :mismatch_setting => true, :scope_hash => 'stale-scope-hash') + ) + end + + test "rejects power change when scope hash is missing" do + assert_scope_hash_conflict(:put, :change_power_state, valid_power_params.except(:scope_hash)) + end + + test "rejects power change when scope hash mismatches" do + assert_scope_hash_conflict(:put, :change_power_state, valid_power_params.merge(:scope_hash => 'stale-scope-hash')) + end + end + private + def valid_scope_hash + 'valid-scope-hash' + end + + def assert_scope_hash_conflict(http_method, action, params) + send(http_method, action, params: params) + + assert_response :conflict + body = ActiveSupport::JSON.decode(@response.body) + assert_match(/Refresh and retry the bulk action/, body['error']['message']) + end + def set_session_user { :user => users(:admin).id } end diff --git a/test/controllers/api/v2/hosts_controller_test.rb b/test/controllers/api/v2/hosts_controller_test.rb index ed4b545e9e..8522a02be0 100644 --- a/test/controllers/api/v2/hosts_controller_test.rb +++ b/test/controllers/api/v2/hosts_controller_test.rb @@ -175,6 +175,16 @@ def last_record assert_equal "OK", host_data['global_status_label'] end + test "should get bulk scope hash" do + ::Hosts::BulkScopeHash.stubs(:for_current_viewer).returns('test-scope-hash') + + get :bulk_scope_hash + + assert_response :success + response = ActiveSupport::JSON.decode(@response.body) + assert_equal 'test-scope-hash', response['scope_hash'] + end + test "subtotal should be the same as the search count with thin" do FactoryBot.create_list(:host, 2) Host.last.update_attribute(:name, 'test') diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/__tests__/bulkDelete.test.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/__tests__/bulkDelete.test.js index f132aaffae..5351f9ebec 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/__tests__/bulkDelete.test.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/__tests__/bulkDelete.test.js @@ -39,6 +39,7 @@ describe('bulkDeleteHosts', () => { const bulkParams = 'id ^ (1,2,3)'; const organizationId = 1; const locationId = 2; + const bulkScopeHash = 'test-scope-hash'; const selectedCount = 3; const destroyVmOnHostDelete = true; @@ -47,6 +48,7 @@ describe('bulkDeleteHosts', () => { bulkParams, organizationId, locationId, + bulkScopeHash, selectedCount, destroyVmOnHostDelete, })(dispatch); @@ -67,6 +69,7 @@ describe('bulkDeleteHosts', () => { bulkParams, organizationId, locationId, + bulkScopeHash, selectedCount, destroyVmOnHostDelete, })(dispatch); @@ -86,6 +89,7 @@ describe('bulkDeleteHosts', () => { expect(requestUrl.searchParams.get('location_id')).toBe( String(locationId) ); + expect(requestUrl.searchParams.get('scope_hash')).toBe(bulkScopeHash); expect(deleteParams.key).toBe('BULK-HOSTS-DELETE'); expect(typeof deleteParams.successToast).toBe('function'); expect(typeof deleteParams.errorToast).toBe('function'); @@ -102,6 +106,7 @@ describe('bulkDeleteHosts', () => { bulkParams, organizationId, locationId, + bulkScopeHash, selectedCount, destroyVmOnHostDelete, onDeleteSuccess, @@ -124,6 +129,7 @@ describe('bulkDeleteHosts', () => { bulkParams, organizationId, locationId, + bulkScopeHash, selectedCount, destroyVmOnHostDelete, })(dispatch); @@ -144,6 +150,7 @@ describe('bulkDeleteHosts', () => { bulkParams, organizationId, locationId, + bulkScopeHash, selectedCount, destroyVmOnHostDelete, })(dispatch); diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyModal.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyModal.js index 3ae3450866..e8ac566408 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyModal.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyModal.js @@ -55,6 +55,7 @@ const BulkAssignTaxonomyModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, modalType, }) => { const org = modalType === MODAL_TYPES.ORGANIZATION; @@ -143,6 +144,7 @@ const BulkAssignTaxonomyModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, id: taxId, mismatch_setting: fixRadioChecked, }); @@ -248,6 +250,7 @@ BulkAssignTaxonomyModal.propTypes = { fetchBulkParams: PropTypes.func.isRequired, organizationId: PropTypes.number, locationId: PropTypes.number, + bulkScopeHash: PropTypes.string, modalType: PropTypes.string.isRequired, }; @@ -256,4 +259,5 @@ BulkAssignTaxonomyModal.defaultProps = { closeModal: () => {}, organizationId: undefined, locationId: undefined, + bulkScopeHash: undefined, }; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/index.js index 4375e6b447..c406f78a95 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/index.js @@ -13,6 +13,7 @@ export const BulkAssignOrganizationModalScene = ({ isOpen, closeModal }) => { fetchBulkParams, organizationId, locationId, + bulkScopeHash, } = useContext(ForemanActionsBarContext); return ( { fetchBulkParams={fetchBulkParams} organizationId={organizationId} locationId={locationId} + bulkScopeHash={bulkScopeHash} isOpen={isOpen} closeModal={closeModal} /> @@ -35,6 +37,7 @@ export const BulkAssignLocationModalScene = ({ isOpen, closeModal }) => { fetchBulkParams, organizationId, locationId, + bulkScopeHash, } = useContext(ForemanActionsBarContext); return ( { fetchBulkParams={fetchBulkParams} organizationId={organizationId} locationId={locationId} + bulkScopeHash={bulkScopeHash} isOpen={isOpen} closeModal={closeModal} /> diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/buildHosts/BulkBuildHostModal.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/buildHosts/BulkBuildHostModal.js index eea9df3c7e..13b81e4ee9 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/buildHosts/BulkBuildHostModal.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/buildHosts/BulkBuildHostModal.js @@ -24,6 +24,7 @@ const BulkBuildHostModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, }) => { const dispatch = useDispatch(); const [buildRadioChecked, setBuildRadioChecked] = useState(true); @@ -50,6 +51,7 @@ const BulkBuildHostModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, reboot: rebootChecked, rebuild_configuration: !buildRadioChecked, }); @@ -160,6 +162,7 @@ BulkBuildHostModal.propTypes = { fetchBulkParams: PropTypes.func.isRequired, organizationId: PropTypes.number, locationId: PropTypes.number, + bulkScopeHash: PropTypes.string, }; BulkBuildHostModal.defaultProps = { @@ -167,6 +170,7 @@ BulkBuildHostModal.defaultProps = { closeModal: () => {}, organizationId: undefined, locationId: undefined, + bulkScopeHash: undefined, }; export default BulkBuildHostModal; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/buildHosts/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/buildHosts/index.js index b143c30973..f15ceda1ed 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/buildHosts/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/buildHosts/index.js @@ -9,6 +9,7 @@ const BulkBuildHostModalScene = ({ isOpen, closeModal }) => { fetchBulkParams, organizationId, locationId, + bulkScopeHash, } = useContext(ForemanActionsBarContext); return ( { fetchBulkParams={fetchBulkParams} organizationId={organizationId} locationId={locationId} + bulkScopeHash={bulkScopeHash} isOpen={isOpen} closeModal={closeModal} /> diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/bulkDelete.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/bulkDelete.js index 5c12e9d7f7..f53ddcf222 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/bulkDelete.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/bulkDelete.js @@ -11,14 +11,17 @@ export const bulkDeleteHosts = ({ bulkParams, organizationId, locationId, + bulkScopeHash, selectedCount, destroyVmOnHostDelete, onDeleteSuccess, }) => dispatch => { const successToast = () => sprintf(__('%s hosts deleted'), selectedCount); - const errorToast = ({ message }) => message; + const errorToast = ({ response, message }) => + response?.data?.error?.message || message; const queryParams = new URLSearchParams({ search: bulkParams, + ...(bulkScopeHash ? { scope_hash: bulkScopeHash } : {}), ...bulkActionTaxonomyParams({ organizationId, locationId }), }); const url = foremanUrl(`/api/v2/hosts/bulk?${queryParams.toString()}`); diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/changeOwner/BulkChangeOwnerModal.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/changeOwner/BulkChangeOwnerModal.js index d48e9d9e0c..9c1d391f6d 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/changeOwner/BulkChangeOwnerModal.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/changeOwner/BulkChangeOwnerModal.js @@ -44,6 +44,7 @@ const BulkChangeOwnerModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, }) => { const dispatch = useDispatch(); const [ownerId, setOwnerId] = useState(''); @@ -137,6 +138,7 @@ const BulkChangeOwnerModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, owner_id: ownerId, }); @@ -254,6 +256,7 @@ BulkChangeOwnerModal.propTypes = { selectAllHostsMode: PropTypes.bool.isRequired, organizationId: PropTypes.number, locationId: PropTypes.number, + bulkScopeHash: PropTypes.string, }; BulkChangeOwnerModal.defaultProps = { @@ -261,6 +264,7 @@ BulkChangeOwnerModal.defaultProps = { closeModal: () => {}, organizationId: undefined, locationId: undefined, + bulkScopeHash: undefined, }; export default BulkChangeOwnerModal; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/changeOwner/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/changeOwner/index.js index 454e56ca50..cd13dd9370 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/changeOwner/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/changeOwner/index.js @@ -11,6 +11,7 @@ const BulkChangeOwnerModalScene = ({ isOpen, closeModal }) => { fetchBulkParams, organizationId, locationId, + bulkScopeHash, } = useContext(ForemanActionsBarContext); return ( { fetchBulkParams={fetchBulkParams} organizationId={organizationId} locationId={locationId} + bulkScopeHash={bulkScopeHash} isOpen={isOpen} closeModal={closeModal} /> diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/BulkDisassociateModal.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/BulkDisassociateModal.js index d8b7ec3300..1534610861 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/BulkDisassociateModal.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/BulkDisassociateModal.js @@ -29,6 +29,7 @@ const BulkDisassociateModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, }) => { const dispatch = useDispatch(); const hostsWithComputeResource = selectedResults?.filter( @@ -97,6 +98,7 @@ const BulkDisassociateModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, includedSearch: queryString, }); @@ -193,6 +195,7 @@ BulkDisassociateModal.propTypes = { selectAllHostsMode: PropTypes.bool.isRequired, organizationId: PropTypes.number, locationId: PropTypes.number, + bulkScopeHash: PropTypes.string, }; BulkDisassociateModal.defaultProps = { @@ -201,6 +204,7 @@ BulkDisassociateModal.defaultProps = { selectedResults: [], organizationId: undefined, locationId: undefined, + bulkScopeHash: undefined, }; export default BulkDisassociateModal; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/index.js index 169eb72f36..f3981ac3b6 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/index.js @@ -11,6 +11,7 @@ const BulkDisassociateModalScene = ({ isOpen, closeModal }) => { fetchBulkParams, organizationId, locationId, + bulkScopeHash, } = useContext(ForemanActionsBarContext); return ( { fetchBulkParams={fetchBulkParams} organizationId={organizationId} locationId={locationId} + bulkScopeHash={bulkScopeHash} isOpen={isOpen} closeModal={closeModal} /> diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/helpers.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/helpers.js index e51c1ba8cc..097f1d172f 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/helpers.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/helpers.js @@ -20,6 +20,7 @@ export const buildBulkRequestBody = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, includedSearch, ...params }) => ({ @@ -27,6 +28,7 @@ export const buildBulkRequestBody = ({ search: includedSearch || fetchBulkParams(), }, ...bulkActionTaxonomyParams({ organizationId, locationId }), + ...(bulkScopeHash ? { scope_hash: bulkScopeHash } : {}), ...params, }); diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/BulkPowerStateModal.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/BulkPowerStateModal.js index 8cb93e5b10..702f2ad0b8 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/BulkPowerStateModal.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/BulkPowerStateModal.js @@ -33,6 +33,7 @@ const BulkPowerStateModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, isOpen, closeModal, }) => { @@ -108,6 +109,7 @@ const BulkPowerStateModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, power: selectedPowerState, }); dispatch(bulkChangePowerState(payload, handleSuccess, handleError)); @@ -210,6 +212,7 @@ BulkPowerStateModal.propTypes = { fetchBulkParams: PropTypes.func.isRequired, organizationId: PropTypes.number, locationId: PropTypes.number, + bulkScopeHash: PropTypes.string, isOpen: PropTypes.bool, closeModal: PropTypes.func, }; @@ -218,6 +221,7 @@ BulkPowerStateModal.defaultProps = { selectedHostsCount: 0, organizationId: undefined, locationId: undefined, + bulkScopeHash: undefined, isOpen: false, closeModal: () => {}, }; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/index.js index f67e800a9e..e422e5fe00 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/index.js @@ -9,6 +9,7 @@ const BulkPowerStateModalScene = ({ isOpen, closeModal }) => { selectedCount = 0, organizationId, locationId, + bulkScopeHash, } = useContext(ForemanActionsBarContext); return ( { fetchBulkParams={fetchBulkParams} organizationId={organizationId} locationId={locationId} + bulkScopeHash={bulkScopeHash} isOpen={isOpen} closeModal={closeModal} /> diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/reassignHostGroup/BulkReassignHostgroupModal.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/reassignHostGroup/BulkReassignHostgroupModal.js index 3e6c051a85..1e1356fe38 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/reassignHostGroup/BulkReassignHostgroupModal.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/reassignHostGroup/BulkReassignHostgroupModal.js @@ -79,6 +79,7 @@ const BulkReassignHostgroupModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, }) => { const dispatch = useDispatch(); const [hostgroupId, setHostgroupId] = useState(''); @@ -157,6 +158,7 @@ const BulkReassignHostgroupModal = ({ fetchBulkParams, organizationId, locationId, + bulkScopeHash, hostgroup_id: hostgroupId, }); @@ -289,6 +291,7 @@ BulkReassignHostgroupModal.propTypes = { fetchBulkParams: PropTypes.func.isRequired, organizationId: PropTypes.number, locationId: PropTypes.number, + bulkScopeHash: PropTypes.string, }; BulkReassignHostgroupModal.defaultProps = { @@ -296,6 +299,7 @@ BulkReassignHostgroupModal.defaultProps = { closeModal: () => {}, organizationId: undefined, locationId: undefined, + bulkScopeHash: undefined, }; export default BulkReassignHostgroupModal; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/reassignHostGroup/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/reassignHostGroup/index.js index d7a4669a73..6961083c84 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/reassignHostGroup/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/reassignHostGroup/index.js @@ -9,6 +9,7 @@ const BulkReassignHostgroupModalScene = ({ isOpen, closeModal }) => { fetchBulkParams, organizationId, locationId, + bulkScopeHash, } = useContext(ForemanActionsBarContext); return ( { fetchBulkParams={fetchBulkParams} organizationId={organizationId} locationId={locationId} + bulkScopeHash={bulkScopeHash} isOpen={isOpen} closeModal={closeModal} /> diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js index f8b82efd1e..d33a39d1db 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js @@ -29,6 +29,7 @@ import { translate as __ } from '../../common/I18n'; import TableIndexPage from '../PF4/TableIndexPage/TableIndexPage'; import { ActionKebab } from './ActionKebab'; import { HOSTS_API_PATH, API_REQUEST_KEY } from '../../routes/Hosts/constants'; +import { useAPI } from '../../common/hooks/API/APIHooks'; import { selectKebabItems } from './Selectors'; import { useBulkSelect, @@ -115,9 +116,14 @@ const HostsIndex = () => { defaultParams, syncWithOptions: true, }); + const { response: bulkScopeHashResponse } = useAPI( + 'get', + '/api/v2/hosts/bulk_scope_hash' + ); const contextData = useForemanContext(); const currentOrganization = useForemanOrganization(); const currentLocation = useForemanLocation(); + const bulkScopeHash = bulkScopeHashResponse?.['scope_hash'] || null; const { response: { @@ -246,6 +252,7 @@ const HostsIndex = () => { bulkParams, organizationId: currentOrganization?.id, locationId: currentLocation?.id, + bulkScopeHash, selectedCount, destroyVmOnHostDelete, onDeleteSuccess: () => { @@ -597,6 +604,7 @@ const HostsIndex = () => { fetchBulkParams, organizationId: currentOrganization?.id, locationId: currentLocation?.id, + bulkScopeHash, }} > ({ useForemanLocation: jest.fn(() => undefined), })); +jest.mock('../../common/hooks/API/APIHooks', () => ({ + useAPI: jest.fn(() => ({ + response: { scope_hash: 'test-scope-hash' }, + })), +})); + jest.mock('../common/Slot', () => ({ __esModule: true, default: () => null, @@ -137,6 +144,7 @@ describe('HostsIndex', () => { beforeEach(() => { capturedTableProps = null; + useAPI.mockClear(); }); test('merges API response page and perPage into params passed to Table', () => { @@ -158,6 +166,16 @@ describe('HostsIndex', () => { }); }); + test('requests the bulk scope hash via useAPI', () => { + render( + + + + ); + + expect(useAPI).toHaveBeenCalledWith('get', '/api/v2/hosts/bulk_scope_hash'); + }); + test('returns an explicit all-hosts search for empty select-all queries', () => { expect( getScheduleJobSearch({