diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue index ebf0650f7d4eddd23fab3883eb7086fd4fc47d22..9adc67e22f598d40024a8583c427deffd1b81f61 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -4,7 +4,8 @@ import { debounce, isEmpty } from 'lodash'; import { __ } from '~/locale'; import { getUsers } from '~/rest_api'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; -import { memberName } from '../utils/member_utils'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { memberName, searchUsers } from '../utils/member_utils'; import { SEARCH_DELAY, USERS_FILTER_ALL, @@ -22,6 +23,8 @@ export default { GlIcon, GlSprintf, }, + mixins: [glFeatureFlagsMixin()], + inject: ['searchUrl'], props: { placeholder: { type: String, @@ -133,6 +136,9 @@ export default { })); }, retrieveUsersRequest() { + if (this.glFeatures.newImplementationOfInviteMembersSearch) { + return searchUsers(this.searchUrl, this.query); + } return getUsers(this.query, this.queryOptions); }, retrieveUsers: debounce(async function debouncedRetrieveUsers() { diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index 1b7568b7f3ca9cc7739303af97062eecb1eaacf6..e55d8aa930d0ff7bc25504ceb28b72c9042c042e 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -29,6 +29,7 @@ export default (function initInviteMembersModal() { hasGitlabSubscription: parseBoolean(el.dataset.hasGitlabSubscription), addSeatsHref: el.dataset.addSeatsHref, hasBsoEnabled: parseBoolean(el.dataset.hasBsoFeatureEnabled), + searchUrl: el.dataset.searchUrl, }, render: (createElement) => createElement(InviteMembersModal, { diff --git a/app/assets/javascripts/invite_members/utils/member_utils.js b/app/assets/javascripts/invite_members/utils/member_utils.js index 7998cb69445f2426b1012f51cbce98dbe478872e..25dbc102e38ca384b44d7562c87124b77e5857c0 100644 --- a/app/assets/javascripts/invite_members/utils/member_utils.js +++ b/app/assets/javascripts/invite_members/utils/member_utils.js @@ -1,8 +1,20 @@ +import { DEFAULT_PER_PAGE } from '~/api'; +import axios from '~/lib/utils/axios_utils'; + export function memberName(member) { // user defined tokens(invites by email) will have email in `name` and will not contain `username` return member.username || member.name; } +export function searchUsers(url, search) { + return axios.get(url, { + params: { + search, + per_page: DEFAULT_PER_PAGE, + }, + }); +} + export function triggerExternalAlert() { return false; } diff --git a/app/controllers/concerns/members/invite_modal_actions.rb b/app/controllers/concerns/members/invite_modal_actions.rb new file mode 100644 index 0000000000000000000000000000000000000000..b4c16a5b0d7c7909e59976f7a776ebca5436e924 --- /dev/null +++ b/app/controllers/concerns/members/invite_modal_actions.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Members + module InviteModalActions + extend ActiveSupport::Concern + + def invite_search + users = Members::InviteUsersFinder.new(current_user, source, search: invite_search_params[:search]).execute + .page(1) + .per(invite_search_per_page) + + render json: UserSerializer.new.represent(users) + end + + private + + def invite_search_per_page + (pagination_params[:per_page] || 20).to_i + end + + def invite_search_params + params.permit(:search) + end + end +end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index eecf85024f490251c4ade5d491b6a268fc2b2cf3..5bef5ba4af3a9f310eb70a7a12dd38eebd4b28ab 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -2,6 +2,7 @@ class Groups::GroupMembersController < Groups::ApplicationController include MembershipActions + include Members::InviteModalActions include MembersPresentation include SortingHelper include Gitlab::Utils::StrongMemoize diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index b0bfce96f6ce850924fb348d1688dfdd02d7d8d3..50522312456fa840b904fa94a572128ced497984 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -2,6 +2,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController include MembershipActions + include Members::InviteModalActions include MembersPresentation include SortingHelper diff --git a/app/finders/members/invite_users_finder.rb b/app/finders/members/invite_users_finder.rb new file mode 100644 index 0000000000000000000000000000000000000000..23fb06953a41802764ea75c26f120dc48763b89e --- /dev/null +++ b/app/finders/members/invite_users_finder.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Used for searching users that can be added to group/project members +# +# Arguments: +# current_user - which user use +# resource - group or project +# search: string +module Members + class InviteUsersFinder < UsersFinder + attr_reader :resource + + def initialize(current_user, resource, search: nil) + @current_user = current_user + @resource = resource + @params = { search: search } + end + + def base_scope + users = User.active.without_project_bot + + users = scope_for_resource(users) + + users.order_id_desc + end + + def scope_for_resource(users) + users + end + end +end + +Members::InviteUsersFinder.prepend_mod diff --git a/app/views/groups/_invite_members_modal.html.haml b/app/views/groups/_invite_members_modal.html.haml index 0306210eb1a7971b0ec8fd4b128d77c13e5d7ebd..97f64063eff9dac48b78fdd2fb899ea531b720c5 100644 --- a/app/views/groups/_invite_members_modal.html.haml +++ b/app/views/groups/_invite_members_modal.html.haml @@ -3,4 +3,5 @@ .js-invite-members-modal{ data: { is_project: 'false', access_levels: access_level_roles_user_can_assign(group, group.access_level_roles).to_json, reload_page_on_submit: content_for(:reload_on_member_invite_success).present?.to_s, + search_url: invite_search_group_group_members_url(group, format: :json), help_link: help_page_url('user/permissions.md') }.merge(common_invite_modal_dataset(group)).merge(users_filter_data(group)) } diff --git a/app/views/projects/_invite_members_modal.html.haml b/app/views/projects/_invite_members_modal.html.haml index a980e746560129272843a7b056b42ba10b49e7c1..41bf23165522cf76b9bf382416f123d50b183c6a 100644 --- a/app/views/projects/_invite_members_modal.html.haml +++ b/app/views/projects/_invite_members_modal.html.haml @@ -3,4 +3,5 @@ .js-invite-members-modal{ data: { is_project: 'true', access_levels: ProjectMember.permissible_access_level_roles(current_user, project).to_json, reload_page_on_submit: content_for(:reload_on_member_invite_success).present?.to_s, + search_url: invite_search_namespace_project_project_members_url(namespace_id: project.namespace, project_id: project, format: :json), help_link: help_page_url('user/permissions.md') }.merge(common_invite_modal_dataset(project)).merge(users_filter_data(project.group)) } diff --git a/config/feature_flags/gitlab_com_derisk/new_implementation_of_invite_members_search.yml b/config/feature_flags/gitlab_com_derisk/new_implementation_of_invite_members_search.yml new file mode 100644 index 0000000000000000000000000000000000000000..f5c92e3547713628a25b11657aa7c0639272a63b --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/new_implementation_of_invite_members_search.yml @@ -0,0 +1,10 @@ +--- +name: new_implementation_of_invite_members_search +description: New implementation of "Invite Members" search +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/460261 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/190070 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/540306 +milestone: '18.1' +group: group::authentication +type: gitlab_com_derisk +default_enabled: false diff --git a/config/routes/group.rb b/config/routes/group.rb index e09db9ec5af3224e16d098daeba5e2f7ddc19724..e32acd2bb2c483371bbcd1879c3fc0a401ebf334 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -128,6 +128,8 @@ end delete :leave + + get :invite_search, format: :json end end diff --git a/config/routes/project.rb b/config/routes/project.rb index f17b1aac9be6535bc692635102b9863cfd761efb..1a6403fedc2c4aeb460ef05502fd7f349058079c 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -189,6 +189,8 @@ resources :project_members, except: [:show, :new, :create, :edit], constraints: { id: %r{[a-zA-Z./0-9_\-#%+:]+} }, concerns: :access_requestable do collection do delete :leave + + get :invite_search, format: :json end member do diff --git a/ee/app/finders/ee/members/invite_users_finder.rb b/ee/app/finders/ee/members/invite_users_finder.rb new file mode 100644 index 0000000000000000000000000000000000000000..41880683bce8476f040b7c4efdd0ef270c7e26a2 --- /dev/null +++ b/ee/app/finders/ee/members/invite_users_finder.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module EE + module Members + module InviteUsersFinder + extend ::Gitlab::Utils::Override + include ::Gitlab::Utils::StrongMemoize + + private + + def root_group + root_ancestor = resource.root_ancestor + + root_ancestor if root_ancestor.group_namespace? + end + strong_memoize_attr :root_group + + override :scope_for_resource + def scope_for_resource(users) + if root_group && root_group.enforced_sso? + ::User.from_union( + users.with_saml_provider(root_group.saml_provider), + users.service_account.with_provisioning_group(root_group) + ) + else + super + end + end + end + end +end diff --git a/ee/spec/features/groups/members/list_members_spec.rb b/ee/spec/features/groups/members/list_members_spec.rb index bd3f4c75560e88602bcf147ebf045fe6038ab2db..1801f807d5505826cafe885053f536418985de17 100644 --- a/ee/spec/features/groups/members/list_members_spec.rb +++ b/ee/spec/features/groups/members/list_members_spec.rb @@ -122,6 +122,31 @@ expect(page).not_to have_content(user4.name) end end + + context 'when new_implementation_of_invite_members_search FF is disabled' do + before do + stub_feature_flags(new_implementation_of_invite_members_search: false) + end + + it 'returns only users with SAML in autocomplete', :js do + visit group_group_members_path(group) + + click_on 'Invite members' + + page.within invite_modal_selector do + field = find(member_dropdown_selector) + field.native.send_keys :tab + field.click + + wait_for_requests + + expect(page).to have_content(user1.name) + expect(page).to have_content(user2.name) + expect(page).not_to have_content(user3.name) + expect(page).not_to have_content(user4.name) + end + end + end end context 'when over free user limit', :saas do diff --git a/ee/spec/finders/ee/members/invite_users_finder_spec.rb b/ee/spec/finders/ee/members/invite_users_finder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..41cddc92a70b0264effa473fb53c9e1de64baf90 --- /dev/null +++ b/ee/spec/finders/ee/members/invite_users_finder_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Members::InviteUsersFinder, feature_category: :groups_and_projects do + let_it_be(:current_user) { create(:user) } + let_it_be(:root_group) { create(:group) } + let_it_be(:subgroup) { create(:group, parent: root_group) } + let_it_be(:project) { create(:project, namespace: root_group, creator: current_user) } + + let_it_be(:regular_user) { create(:user) } + let_it_be(:admin_user) { create(:user, :admin) } + let_it_be(:banned_user) { create(:user, :banned) } + let_it_be(:blocked_user) { create(:user, :blocked) } + let_it_be(:ldap_blocked_user) { create(:user, :ldap_blocked) } + let_it_be(:external_user) { create(:user, :external) } + let_it_be(:unconfirmed_user) { create(:user, confirmed_at: nil) } + let_it_be(:omniauth_user) { create(:omniauth_user) } + let_it_be(:internal_user) { Users::Internal.alert_bot } + let_it_be(:project_bot_user) { create(:user, :project_bot) } + let_it_be(:service_account_user) { create(:user, :service_account) } + + before_all do + root_group.add_owner(current_user) + end + + subject(:finder) do + described_class.new(current_user, resource) + end + + describe '#execute' do + context 'for SSO enforcement requirements' do + let_it_be(:resource) { project } + + let_it_be_with_reload(:saml_provider) { create(:saml_provider, group: root_group, enforced_sso: true) } + + let_it_be(:user_with_group_saml_identity) do + create(:user).tap do |user| + create(:group_saml_identity, saml_provider: saml_provider, user: user) + end + end + + let_it_be(:blocked_user_with_group_saml_identity) do + create(:user, :blocked).tap do |user| + create(:group_saml_identity, saml_provider: saml_provider, user: user) + end + end + + let_it_be(:group_service_account) { create(:service_account, provisioned_by_group: root_group) } + let_it_be(:blocked_group_service_account) { create(:service_account, :blocked, provisioned_by_group: root_group) } + let_it_be(:another_group_service_account) { create(:service_account, provisioned_by_group: create(:group)) } + + let(:searchable_group_users_ordered_by_id_desc) do + [ + user_with_group_saml_identity, + group_service_account + ].sort_by(&:id).reverse + end + + let(:searchable_users_ordered_by_id_desc) do + [ + current_user, + regular_user, + admin_user, + external_user, + unconfirmed_user, + omniauth_user, + service_account_user, + *searchable_group_users_ordered_by_id_desc, + another_group_service_account + ].sort_by(&:id).reverse + end + + before do + stub_licensed_features(group_saml: true) + end + + context 'when SSO enforcement is enabled' do + it 'returns searchable users scoped for the resource and ordered by id descending' do + expect(finder.execute).to eq(searchable_group_users_ordered_by_id_desc) + end + end + + context 'when SSO enforcement is disabled' do + before do + saml_provider.update!(enforced_sso: false) + end + + it 'returns searchable users ordered by id descending' do + expect(finder.execute).to eq(searchable_users_ordered_by_id_desc) + end + end + end + end +end diff --git a/ee/spec/models/ee/user_spec.rb b/ee/spec/models/ee/user_spec.rb index 5708ef4b7a35a1fb8811501e6ce34c8b5d22fca2..8a31923234b640af12f3b89a5eba4d1a54c4b9ca 100644 --- a/ee/spec/models/ee/user_spec.rb +++ b/ee/spec/models/ee/user_spec.rb @@ -525,7 +525,7 @@ expect(with_provisioning_group).to match_array([user]) end - it 'does not find users with a different SAML provider' do + it 'does not find users with a different provisioning group' do group = create(:group) expect(described_class.with_provisioning_group(group)).to be_empty diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index b418c075a39e94b21deb84545724072a815692f1..82ed9e136a8415666fe4990e54de84e59b72f433 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -93,6 +93,7 @@ def add_gon_variables push_frontend_feature_flag(:new_project_creation_form, current_user, type: :wip) push_frontend_feature_flag(:work_items_client_side_boards, current_user) push_frontend_feature_flag(:glql_work_items, current_user, type: :wip) + push_frontend_feature_flag(:new_implementation_of_invite_members_search) end # Exposes the state of a feature flag to the frontend code. diff --git a/spec/finders/members/invite_users_finder_spec.rb b/spec/finders/members/invite_users_finder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b3a9bdaa352b465eb071a4e706be8301789d2745 --- /dev/null +++ b/spec/finders/members/invite_users_finder_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Members::InviteUsersFinder, feature_category: :groups_and_projects do + let_it_be(:current_user) { create(:user, :with_namespace) } + let_it_be(:root_group) { create(:group) } + + let_it_be(:regular_user) { create(:user) } + let_it_be(:admin_user) { create(:user, :admin) } + let_it_be(:banned_user) { create(:user, :banned) } + let_it_be(:blocked_user) { create(:user, :blocked) } + let_it_be(:ldap_blocked_user) { create(:user, :ldap_blocked) } + let_it_be(:external_user) { create(:user, :external) } + let_it_be(:unconfirmed_user) { create(:user, confirmed_at: nil) } + let_it_be(:omniauth_user) { create(:omniauth_user) } + let_it_be(:internal_user) { Users::Internal.alert_bot } + let_it_be(:project_bot_user) { create(:user, :project_bot) } + let_it_be(:service_account_user) { create(:user, :service_account) } + + before_all do + root_group.add_owner(current_user) + end + + subject(:finder) do + described_class.new(current_user, resource) + end + + describe '#execute' do + shared_examples 'searchable' do + let(:searchable_users_ordered_by_id_desc) do + [ + current_user, + regular_user, + admin_user, + external_user, + unconfirmed_user, + omniauth_user, + service_account_user + ].sort_by(&:id).reverse + end + + it 'returns searchable users ordered by id descending' do + expect(finder.execute).to eq(searchable_users_ordered_by_id_desc) + end + + context 'for search param' do + subject(:finder) do + described_class.new(current_user, resource, search: search) + end + + context 'with empty string' do + let(:search) { '' } + + it 'returns searchable users ordered by id descending' do + expect(finder.execute).to eq(searchable_users_ordered_by_id_desc) + end + end + + context "with a user's name" do + let(:search) { regular_user.name } + + it 'returns users that match the name' do + expect(finder.execute).to eq([regular_user]) + end + end + end + end + + context 'for root_group' do + let_it_be(:resource) { root_group } + + include_examples 'searchable' + end + + context 'for subgroup' do + let_it_be(:subgroup) { create(:group, parent: root_group) } + let_it_be(:resource) { subgroup } + + include_examples 'searchable' + end + + context 'for project within group namespace' do + let_it_be(:project) { create(:project, namespace: root_group, creator: current_user) } + let_it_be(:resource) { project } + + include_examples 'searchable' + end + + context 'for project within user namespace' do + let_it_be(:project) { create(:project, namespace: current_user.namespace) } + let_it_be(:resource) { project } + + include_examples 'searchable' + end + end +end diff --git a/spec/frontend/invite_members/components/members_token_select_spec.js b/spec/frontend/invite_members/components/members_token_select_spec.js index a83e38d3a83d0a23ead9423ad55dbaf207ee1d27..31ae2aae0fcdfb25c4a72a00034d911396084857 100644 --- a/spec/frontend/invite_members/components/members_token_select_spec.js +++ b/spec/frontend/invite_members/components/members_token_select_spec.js @@ -5,6 +5,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import * as UserApi from '~/api/user_api'; import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; import { VALID_TOKEN_BACKGROUND, INVALID_TOKEN_BACKGROUND } from '~/invite_members/constants'; +import * as MembersUtils from '~/invite_members/utils/member_utils'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; const label = 'testgroup'; @@ -16,8 +17,9 @@ const handleEnterSpy = jest.fn(); /** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */ let wrapper; +const searchUrl = 'https://example.com/gitlab/groups/mygroup/-/group_members/invite_search.json'; -const createComponent = ({ props = {} } = {}) => { +const createComponent = ({ props = {}, glFeatures = {} } = {}) => { wrapper = mountExtended(MembersTokenSelect, { propsData: { ariaLabelledby: label, @@ -25,6 +27,7 @@ const createComponent = ({ props = {} } = {}) => { placeholder, ...props, }, + provide: { glFeatures, searchUrl }, }); }; @@ -89,6 +92,25 @@ describe('MembersTokenSelect', () => { }); describe('users', () => { + describe('when `newImplementationOfInviteMembersSearch` is enabled', () => { + let tokenSelector; + + beforeEach(() => { + jest.spyOn(MembersUtils, 'searchUsers').mockResolvedValue({ data: allUsers }); + createComponent({ glFeatures: { newImplementationOfInviteMembersSearch: true } }); + tokenSelector = findTokenSelector(); + }); + + it('calls the API with search parameter with whitespaces and is trimmed', async () => { + tokenSelector.vm.$emit('text-input', ' foo@bar.com '); + + await waitForPromises(); + + expect(MembersUtils.searchUsers).toHaveBeenCalledWith(searchUrl, 'foo@bar.com'); + expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false); + }); + }); + beforeEach(() => { jest.spyOn(UserApi, 'getUsers').mockResolvedValue({ data: allUsers }); createComponent(); diff --git a/spec/frontend/invite_members/utils/member_utils_spec.js b/spec/frontend/invite_members/utils/member_utils_spec.js index abae43c3dbb5f8fe2b0463a704b81a564e5c2a9e..5ea6ffda1306fa9228dca3ff4e4526252d77a5b4 100644 --- a/spec/frontend/invite_members/utils/member_utils_spec.js +++ b/spec/frontend/invite_members/utils/member_utils_spec.js @@ -1,4 +1,7 @@ -import { memberName, triggerExternalAlert } from '~/invite_members/utils/member_utils'; +import MockAdapter from 'axios-mock-adapter'; +import { memberName, searchUsers, triggerExternalAlert } from '~/invite_members/utils/member_utils'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; jest.mock('~/lib/utils/url_utility'); @@ -13,6 +16,25 @@ describe('Member Name', () => { }); }); +describe('searchUsers', () => { + let mockAxios; + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + }); + + it('should call axios.get with correct URL and params', async () => { + const url = 'https://example.com/gitlab/groups/mygroup/-/group_members/invite_search.json'; + const search = 'my user'; + mockAxios.onGet().replyOnce(HTTP_STATUS_OK); + + await searchUsers(url, search); + expect(mockAxios.history.get[0]).toEqual( + expect.objectContaining({ url, params: { search, per_page: 20 } }), + ); + }); +}); + describe('Trigger External Alert', () => { it('returns false', () => { expect(triggerExternalAlert()).toBe(false); diff --git a/spec/requests/groups/group_members_controller_spec.rb b/spec/requests/groups/group_members_controller_spec.rb index 07f7b85efedad5422581a58a30e51c00efd3a597..5b092a0785c85df5cd556d6fa13d776caa51e55a 100644 --- a/spec/requests/groups/group_members_controller_spec.rb +++ b/spec/requests/groups/group_members_controller_spec.rb @@ -29,4 +29,91 @@ it_behaves_like 'request_accessable' end + + describe 'GET /groups/*group_id/-/group_members/invite_search.json' do + subject(:request) do + get invite_search_group_group_members_path(membershipable, params: params, format: :json) + end + + let(:params) { {} } + + let_it_be(:regular_user) { create(:user) } + let_it_be(:admin_user) { create(:user, :admin) } + let_it_be(:banned_user) { create(:user, :banned) } + let_it_be(:blocked_user) { create(:user, :blocked) } + let_it_be(:ldap_blocked_user) { create(:user, :ldap_blocked) } + let_it_be(:external_user) { create(:user, :external) } + let_it_be(:unconfirmed_user) { create(:user, confirmed_at: nil) } + let_it_be(:omniauth_user) { create(:omniauth_user) } + let_it_be(:internal_user) { Users::Internal.alert_bot } + let_it_be(:project_bot_user) { create(:user, :project_bot) } + let_it_be(:service_account_user) { create(:user, :service_account) } + + let(:searchable_users) do + [ + user, + regular_user, + admin_user, + external_user, + unconfirmed_user, + omniauth_user, + service_account_user + ] + end + + before do + sign_in(user) + end + + context 'when user has permission to manage group members' do + before_all do + membershipable.add_owner(user) + end + + it 'returns searchable users' do + request + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to match_array(searchable_users.map(&:id)) + end + + context 'for search param' do + let(:params) { { search: search } } + + context 'with empty string' do + let(:search) { '' } + + it 'returns searchable users' do + request + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to match_array(searchable_users.map(&:id)) + end + end + + context "with a user's name" do + let(:search) { regular_user.name } + + it 'returns users that match the name' do + request + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to contain_exactly(regular_user.id) + end + end + end + end + + context 'when user does not have permission to manage group members' do + before_all do + membershipable.add_maintainer(user) + end + + it 'returns 403 forbidden' do + request + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + end end diff --git a/spec/requests/projects/project_members_controller_spec.rb b/spec/requests/projects/project_members_controller_spec.rb index 8ab6f521766e394ec4f970b440ee46be537ecf07..57bd1ceb0e67695ad31e4527fcf5c1040fadb8be 100644 --- a/spec/requests/projects/project_members_controller_spec.rb +++ b/spec/requests/projects/project_members_controller_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Projects::ProjectMembersController, feature_category: :groups_and_projects do let_it_be(:user) { create(:user) } - let_it_be(:membershipable) { create(:project, :public, namespace: create(:group, :public)) } + let_it_be(:membershipable) { create(:project, :public, namespace: create(:group, :public), creator: user) } let(:membershipable_path) { project_path(membershipable) } @@ -20,4 +20,96 @@ it_behaves_like 'request_accessable' end + + describe 'GET /*namespace_id/:project_id/-/project_members/invite_search.json' do + subject(:request) do + get invite_search_namespace_project_project_members_path( + namespace_id: membershipable.namespace, + project_id: membershipable, + params: params, + format: :json + ) + end + + let(:params) { {} } + + let_it_be(:regular_user) { create(:user) } + let_it_be(:admin_user) { create(:user, :admin) } + let_it_be(:banned_user) { create(:user, :banned) } + let_it_be(:blocked_user) { create(:user, :blocked) } + let_it_be(:ldap_blocked_user) { create(:user, :ldap_blocked) } + let_it_be(:external_user) { create(:user, :external) } + let_it_be(:unconfirmed_user) { create(:user, confirmed_at: nil) } + let_it_be(:omniauth_user) { create(:omniauth_user) } + let_it_be(:internal_user) { Users::Internal.alert_bot } + let_it_be(:project_bot_user) { create(:user, :project_bot) } + let_it_be(:service_account_user) { create(:user, :service_account) } + + let(:searchable_users) do + [ + user, + regular_user, + admin_user, + external_user, + unconfirmed_user, + omniauth_user, + service_account_user + ] + end + + before do + sign_in(user) + end + + context 'when user has permission to manage project members' do + before_all do + membershipable.add_maintainer(user) + end + + it 'returns searchable users' do + request + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to match_array(searchable_users.map(&:id)) + end + + context 'for search param' do + let(:params) { { search: search } } + + context 'with empty string' do + let(:search) { '' } + + it 'returns searchable users' do + request + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to match_array(searchable_users.map(&:id)) + end + end + + context "with a user's name" do + let(:search) { regular_user.name } + + it 'returns users that match the name' do + request + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to contain_exactly(regular_user.id) + end + end + end + end + + context 'when user does not have permission to manage project members' do + before_all do + membershipable.add_developer(user) + end + + it 'returns 404 not_found' do + request + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end end