diff --git a/.rubocop_todo/rspec/be_eq.yml b/.rubocop_todo/rspec/be_eq.yml index b51f7e7c78e46dad1118b6fdeea9c8f996e735e2..65b84951a8649b86a6468d3e54c6d0bed44da019 100644 --- a/.rubocop_todo/rspec/be_eq.yml +++ b/.rubocop_todo/rspec/be_eq.yml @@ -468,7 +468,6 @@ RSpec/BeEq: - 'ee/spec/services/projects/restore_service_spec.rb' - 'ee/spec/services/projects/update_service_spec.rb' - 'ee/spec/services/quick_actions/interpret_service_spec.rb' - - 'ee/spec/services/search/elastic/cluster_reindexing_service_spec.rb' - 'ee/spec/services/search/project_service_spec.rb' - 'ee/spec/services/search/zoekt/indexing_task_service_spec.rb' - 'ee/spec/services/security/configuration/project_set_continuous_vulnerability_scanning_service_spec.rb' diff --git a/.rubocop_todo/rspec/receive_messages.yml b/.rubocop_todo/rspec/receive_messages.yml index 581c114c0d1d6cd9af745a3904526202aaec2492..97b93e6b1bc1cc22a084d9b1b8cc46a023c1c24a 100644 --- a/.rubocop_todo/rspec/receive_messages.yml +++ b/.rubocop_todo/rspec/receive_messages.yml @@ -154,7 +154,6 @@ RSpec/ReceiveMessages: - 'ee/spec/services/merge_requests/mergeability/check_jira_status_service_spec.rb' - 'ee/spec/services/merge_requests/mergeability/check_path_locks_service_spec.rb' - 'ee/spec/services/resource_access_tokens/create_service_spec.rb' - - 'ee/spec/services/search/elastic/cluster_reindexing_service_spec.rb' - 'ee/spec/services/search/project_service_spec.rb' - 'ee/spec/services/security/orchestration/assign_service_spec.rb' - 'ee/spec/services/security/scan_result_policies/generate_policy_violation_comment_service_spec.rb' diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js index 10c22609858cbdd97807c68b73282f8cda28c398..c7ee7699d9ca5848818cdc0b7caf64421da79694 100644 --- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js +++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js @@ -388,9 +388,12 @@ export const PROJECT_FILES_GO_BACK = { defaultKeys: ['esc'], }; +const { blobOverflowMenu } = gon.features ?? {}; export const PROJECT_FILES_GO_TO_PERMALINK = { id: 'projectFiles.goToFilePermalink', - description: __('Go to file permalink (while viewing a file)'), + description: blobOverflowMenu + ? __('Copy file permalink') + : __('Go to file permalink (while viewing a file)'), defaultKeys: ['y'], }; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js index 9f8d7272e5c52e9afca92f48934fc8844a9ab850..13ecfaf1a5493dff78cdeeceef43a29c28ae1388 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js @@ -3,6 +3,12 @@ import { moveToFilePermalink } from '~/blob/utils'; export default class ShortcutsBlob { constructor(shortcuts) { + const { blobOverflowMenu } = gon.features ?? {}; + if (blobOverflowMenu) { + // TODO: Remove ShortcutsBlob entirely once these feature flags are removed. + return; + } + shortcuts.add(PROJECT_FILES_GO_TO_PERMALINK, moveToFilePermalink); } } diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index afc08045afaae2df5618a57ea2c250fee7d474f9..c7a7e33889881bd39dca4b793c22749922bba1f1 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -1,4 +1,5 @@ import { omitBy } from 'lodash'; +import { nextTick } from 'vue'; import Api from '~/api'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; @@ -23,6 +24,7 @@ import { prepareSearchAggregations, setDataToLS, skipBlobESCount, + buildDocumentTitle, } from './utils'; export const fetchGroups = ({ commit }, search) => { @@ -103,7 +105,45 @@ export const setFrequentProject = ({ state, commit }, item) => { commit(types.LOAD_FREQUENT_ITEMS, { key: PROJECTS_LOCAL_STORAGE_KEY, data: frequentItems }); }; -export const setQuery = ({ state, commit, getters }, { key, value }) => { +export const fetchSidebarCount = ({ commit, state }) => { + const items = Object.values(state.navigation) + .filter( + (navigationItem) => + !navigationItem.active && + navigationItem.count_link && + skipBlobESCount(state, navigationItem.scope), + ) + .map((navItem) => { + const navigationItem = { ...navItem }; + + const modifications = { + search: state.query?.search || '*', + }; + + if (navigationItem.scope === SCOPE_BLOB && loadDataFromLS(LS_REGEX_HANDLE)) { + modifications[REGEX_PARAM] = true; + } + + navigationItem.count_link = setUrlParams( + modifications, + getNormalizedURL(navigationItem.count_link), + ); + return navigationItem; + }); + + const promises = items.map((navigationItem) => + axios + .get(navigationItem.count_link) + .then(({ data: { count } }) => { + commit(types.RECEIVE_NAVIGATION_COUNT, { key: navigationItem.scope, count }); + }) + .catch((e) => logError(e)), + ); + + return Promise.all(promises); +}; + +export const setQuery = async ({ state, commit, getters }, { key, value }) => { commit(types.SET_QUERY, { key, value }); if (SIDEBAR_PARAMS.includes(key)) { @@ -117,10 +157,14 @@ export const setQuery = ({ state, commit, getters }, { key, value }) => { if ( state.searchType === SEARCH_TYPE_ZOEKT && getters.currentScope === SCOPE_BLOB && - gon.features.zoektMultimatchFrontend + gon.features?.zoektMultimatchFrontend ) { const newUrl = setUrlParams({ ...state.query }, window.location.href, false, true); - updateHistory({ state: state.query, url: newUrl, replace: true }); + document.title = buildDocumentTitle(state.query.search); + updateHistory({ state: state.query, title: state.query.search, url: newUrl, replace: false }); + + await nextTick(); + fetchSidebarCount({ state, commit }); } }; @@ -148,53 +192,16 @@ export const resetQuery = ({ state }) => { ); }; -export const closeLabel = ({ state, commit }, { title }) => { - const labels = state?.query?.[LABEL_FILTER_PARAM].filter((labelName) => labelName !== title); - setQuery({ state, commit }, { key: LABEL_FILTER_PARAM, value: labels }); +export const closeLabel = ({ state, commit, getters }, { title }) => { + const labels = + state?.query?.[LABEL_FILTER_PARAM]?.filter((labelName) => labelName !== title) || []; + setQuery({ state, commit, getters }, { key: LABEL_FILTER_PARAM, value: labels }); }; export const setLabelFilterSearch = ({ commit }, { value }) => { commit(types.SET_LABEL_SEARCH_STRING, value); }; -export const fetchSidebarCount = ({ commit, state }) => { - const items = Object.values(state.navigation) - .filter( - (navigationItem) => - !navigationItem.active && - navigationItem.count_link && - skipBlobESCount(state, navigationItem.scope), - ) - .map((navItem) => { - const navigationItem = { ...navItem }; - const modifications = { - search: state.query?.search || '*', - }; - - if (navigationItem.scope === SCOPE_BLOB && loadDataFromLS(LS_REGEX_HANDLE)) { - modifications[REGEX_PARAM] = true; - } - - navigationItem.count_link = setUrlParams( - modifications, - getNormalizedURL(navigationItem.count_link), - ); - - return navigationItem; - }); - - const promises = items.map((navigationItem) => - axios - .get(navigationItem.count_link) - .then(({ data: { count } }) => { - commit(types.RECEIVE_NAVIGATION_COUNT, { key: navigationItem.scope, count }); - }) - .catch((e) => logError(e)), - ); - - return Promise.all(promises); -}; - export const fetchAllAggregation = ({ commit, state }) => { commit(types.REQUEST_AGGREGATIONS); return axios diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js index dbb2ce7f11205d8f3a4d5e1107fa087e89da12f3..fa9270ec5ff3a4fa642d59507adf9a1b4643b9fa 100644 --- a/app/assets/javascripts/search/store/constants.js +++ b/app/assets/javascripts/search/store/constants.js @@ -84,3 +84,4 @@ export const SEARCH_LEVEL_PROJECT = 'project'; export const SEARCH_LEVEL_GROUP = 'group'; export const LS_REGEX_HANDLE = `${REGEX_PARAM}_advanced_search`; +export const SEARCH_WINDOW_TITLE = `${s__('GlobalSearch|Search')} · GitLab`; diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js index 7627b2e0e08a7794db0caa653b9addb7b48b29e5..b67f7d2843b7727ec69b3f9633c2603fdf2e08e7 100644 --- a/app/assets/javascripts/search/store/mutations.js +++ b/app/assets/javascripts/search/store/mutations.js @@ -33,7 +33,7 @@ export default { state.frequentItems[key] = data; }, [types.RECEIVE_NAVIGATION_COUNT](state, { key, count }) { - const item = { ...state.navigation[key], count, count_link: null }; + const item = { ...state.navigation[key], count }; state.navigation = { ...state.navigation, [key]: item }; }, [types.REQUEST_AGGREGATIONS](state) { diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js index 4ef2d91c057df1c0c19363e8d9f68b4365d364dc..aa203c0ba0485113ea9431261f00255920d36d60 100644 --- a/app/assets/javascripts/search/store/utils.js +++ b/app/assets/javascripts/search/store/utils.js @@ -14,6 +14,7 @@ import { NUMBER_FORMATING_OPTIONS, REGEX_PARAM, LS_REGEX_HANDLE, + SEARCH_WINDOW_TITLE, } from './constants'; function extractKeys(object, keyList) { @@ -114,7 +115,6 @@ export const mergeById = (inflatedData, storedData) => { export const isSidebarDirty = (currentQuery, urlQuery) => { return SIDEBAR_PARAMS.some((param) => { - // userAddParam ensures we don't get a false dirty from null !== undefined const userAddedParam = !urlQuery[param] && currentQuery[param]; const userChangedExistingParam = urlQuery[param] && urlQuery[param] !== currentQuery[param]; @@ -219,3 +219,22 @@ export const skipBlobESCount = (state, itemScope) => state.zoektAvailable && itemScope === SCOPE_BLOB ); + +export const buildDocumentTitle = (title) => { + const prevTitle = document.title; + + if (prevTitle.includes(SEARCH_WINDOW_TITLE)) { + if (prevTitle.startsWith(SEARCH_WINDOW_TITLE)) { + return `${title} · ${SEARCH_WINDOW_TITLE}`; + } + + if (prevTitle.trim().startsWith(` · ${SEARCH_WINDOW_TITLE}`.trim())) { + return `${title} · ${SEARCH_WINDOW_TITLE}`; + } + + const pattern = new RegExp(`^.*?(?= · ${SEARCH_WINDOW_TITLE})`); + return prevTitle.replace(pattern, title); + } + // If pattern not found, return the original + return title; +}; diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue index 8e04bda01ae784b5af5c2da3b61d9c83a897c5cc..1811a608d7c2c166c2c1e1bc537573f4a40e6c03 100644 --- a/app/assets/javascripts/search/topbar/components/app.vue +++ b/app/assets/javascripts/search/topbar/components/app.vue @@ -1,8 +1,9 @@ <script> import { GlButton } from '@gitlab/ui'; -import { isEmpty } from 'lodash'; +import { isEmpty, debounce } from 'lodash'; // eslint-disable-next-line no-restricted-imports import { mapState, mapActions, mapGetters } from 'vuex'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { InternalEvents } from '~/tracking'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { s__ } from '~/locale'; @@ -51,6 +52,10 @@ export default { return this.query ? this.query.search : ''; }, set(value) { + if (this.isMultiMatch) { + this.debouncedSetQuery({ key: 'search', value }); + return; + } this.setQuery({ key: 'search', value }); }, }, @@ -86,6 +91,7 @@ export default { created() { this.preloadStoredFrequentItems(); this.regexEnabled = loadDataFromLS(LS_REGEX_HANDLE); + this.debouncedSetQuery = debounce(this.setQuery, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); }, methods: { ...mapActions(['applyQuery', 'setQuery', 'preloadStoredFrequentItems']), diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb index d0ed3515bfd6684fabb6714cb38eae2d3b7e8839..d2b4b18f9207f6dd7e62f5919c68066f0dbbf999 100644 --- a/app/finders/users_finder.rb +++ b/app/finders/users_finder.rb @@ -55,6 +55,8 @@ def execute users = by_custom_attributes(users) users = by_non_internal(users) users = by_without_project_bots(users) + users = by_membership(users) + users = by_member_source_ids(users) order(users) end @@ -180,6 +182,48 @@ def by_without_project_bots(users) users.without_project_bot end + def by_membership(users) + return users unless params[:by_membership] + + group_members = Member + .non_request + .with_source(current_user.authorized_groups.self_and_ancestors) + .select(:user_id) + .to_sql + + project_members = Member + .non_request + .with_source(current_user.authorized_projects) + .select(:user_id) + .to_sql + + query = "users.id IN (#{group_members} UNION #{project_members})" + users.where(query) # rubocop: disable CodeReuse/ActiveRecord -- finder + end + + def by_member_source_ids(users) + group_member_source_ids = params[:group_member_source_ids] + project_member_source_ids = params[:project_member_source_ids] + + return users unless group_member_source_ids || project_member_source_ids + + member_queries = [] + + if group_member_source_ids.present? + member_queries << Member.with_source_id(group_member_source_ids).with_source_type('Namespace') + end + + if project_member_source_ids.present? + member_queries << Member.with_source_id(project_member_source_ids).with_source_type('Project') + end + + return users if member_queries.empty? + + member_query = member_queries.reduce(:or).non_request + + users.id_in(member_query.select(:user_id)) + end + def order(users) return users unless params[:sort] diff --git a/app/models/member.rb b/app/models/member.rb index deffb3768a7ada51b05a9cdcfc6aa0bf10bc4406..614315657e03a3497e97bec91c4226bd6b3c0b14 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -209,6 +209,8 @@ class Member < ApplicationRecord end scope :with_source_id, ->(source_id) { where(source_id: source_id) } + scope :with_source, ->(source) { where(source: source) } + scope :with_source_type, ->(source_type) { where(source_type: source_type) } scope :including_source, -> { includes(:source) } scope :including_user, -> { includes(:user) } diff --git a/config/feature_flags/gitlab_com_derisk/users_search_scoped_to_authorized_namespaces_basic_search.yml b/config/feature_flags/gitlab_com_derisk/users_search_scoped_to_authorized_namespaces_basic_search.yml new file mode 100644 index 0000000000000000000000000000000000000000..b7c9a64f6767531aa260ff8ef3d7282c093ad17c --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/users_search_scoped_to_authorized_namespaces_basic_search.yml @@ -0,0 +1,9 @@ +--- +name: users_search_scoped_to_authorized_namespaces_basic_search +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/442091 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/182557 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/520710 +milestone: '17.10' +group: group::global search +type: gitlab_com_derisk +default_enabled: false diff --git a/config/feature_flags/gitlab_com_derisk/users_search_scoped_to_authorized_namespaces_basic_search_by_ids.yml b/config/feature_flags/gitlab_com_derisk/users_search_scoped_to_authorized_namespaces_basic_search_by_ids.yml new file mode 100644 index 0000000000000000000000000000000000000000..fd4fbcbaa7c4006fc32393b13c4277ebab0d0ada --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/users_search_scoped_to_authorized_namespaces_basic_search_by_ids.yml @@ -0,0 +1,9 @@ +--- +name: users_search_scoped_to_authorized_namespaces_basic_search_by_ids +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/442091 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/182557 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/524297 +milestone: '17.10' +group: group::global search +type: gitlab_com_derisk +default_enabled: false diff --git a/ee/app/services/vulnerabilities/auto_resolve_service.rb b/ee/app/services/vulnerabilities/auto_resolve_service.rb index 6fece9ca2fe2288a444c330a024d47e0d96fe2b2..e49ee1905dcd9cec47d2d40b3d82587f445addb8 100644 --- a/ee/app/services/vulnerabilities/auto_resolve_service.rb +++ b/ee/app/services/vulnerabilities/auto_resolve_service.rb @@ -19,7 +19,9 @@ def execute refresh_statistics ServiceResponse.success(payload: { count: vulnerabilities_to_resolve.size }) - rescue ActiveRecord::ActiveRecordError + rescue ActiveRecord::ActiveRecordError => e + Gitlab::ErrorTracking.track_exception(e) + error_response end diff --git a/ee/spec/services/search/elastic/cluster_reindexing_service_spec.rb b/ee/spec/services/search/elastic/cluster_reindexing_service_spec.rb index c69f16aa34428816e6b95245b4af351409fa290c..0c6eba35783c3042afb40edf5995b7d7fcf1b016 100644 --- a/ee/spec/services/search/elastic/cluster_reindexing_service_spec.rb +++ b/ee/spec/services/search/elastic/cluster_reindexing_service_spec.rb @@ -51,20 +51,19 @@ end it 'errors when there is not enough space' do - allow(helper).to receive(:index_size_bytes).and_return(100.megabytes) - allow(helper).to receive(:cluster_free_size_bytes).and_return(30.megabytes) + allow(helper).to receive_messages(index_size_bytes: 100.megabytes, cluster_free_size_bytes: 30.megabytes) expect { cluster_reindexing_service.execute }.to change { task.reload.state }.from('initial').to('failure') expect(task.reload.error_message).to match(/storage available/) end it 'pauses elasticsearch indexing' do - expect(Gitlab::CurrentSettings.elasticsearch_pause_indexing).to eq(false) + expect(Gitlab::CurrentSettings.elasticsearch_pause_indexing).to be(false) expect { cluster_reindexing_service.execute } .to change { task.reload.state }.from('initial').to('indexing_paused') - expect(Gitlab::CurrentSettings.elasticsearch_pause_indexing).to eq(true) + expect(Gitlab::CurrentSettings.elasticsearch_pause_indexing).to be(true) end context 'when partial reindexing' do @@ -93,11 +92,12 @@ let!(:task) { create(:elastic_reindexing_task, state: :indexing_paused, targets: nil) } before do - allow(helper).to receive(:create_standalone_indices).and_return(issues_new_index_name => issues_alias) allow(helper).to receive(:target_index_names) { |options| { "#{options[:target]}-1" => true } } - allow(helper).to receive(:create_empty_index).and_return(main_new_index_name => main_alias) + allow(helper).to receive_messages( + create_standalone_indices: { issues_new_index_name => issues_alias }, + create_empty_index: { main_new_index_name => main_alias } + ) allow(helper).to receive(:reindex) { |options| "#{options[:to]}_task_id" } - allow(helper).to receive(:documents_count) allow(helper).to receive(:get_settings) do |options| number_of_shards = case options[:index_name] when main_old_index_name then 10 @@ -212,11 +212,12 @@ end before do - allow(helper).to receive(:task_status).and_return( - { + allow(helper).to receive_messages( + task_status: { 'completed' => true, 'response' => { 'total' => 20, 'created' => 20, 'updated' => 0, 'deleted' => 0 } - } + }, + refresh_index: true ) allow(helper).to receive(:reindex).and_return('task_1', 'task_2', 'task_3', 'task_4', 'task_5', 'task_6') end diff --git a/ee/spec/services/vulnerabilities/auto_resolve_service_spec.rb b/ee/spec/services/vulnerabilities/auto_resolve_service_spec.rb index 244c7f889002673795347c9fd2dd426afcc17b8e..87d87489b89029b2245f9fe1e8fca4014767da9b 100644 --- a/ee/spec/services/vulnerabilities/auto_resolve_service_spec.rb +++ b/ee/spec/services/vulnerabilities/auto_resolve_service_spec.rb @@ -143,7 +143,8 @@ allow(Note).to receive(:insert_all!).and_raise(ActiveRecord::RecordNotUnique) end - it 'does not bubble up the error' do + it 'does not bubble up the error and tracks the exception' do + expect(Gitlab::ErrorTracking).to receive(:track_exception).with(ActiveRecord::RecordNotUnique) expect { service.execute }.not_to raise_error end diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index b94184cfe8c6d0d39ed226ce727792d8147b7eb6..c795c12b157eb529fc81ff84405f9faf1eaee6f1 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -109,7 +109,18 @@ def count_limit def users return User.none unless Ability.allowed?(current_user, :read_users_list) - UsersFinder.new(current_user, { search: query, use_minimum_char_limit: false }).execute + params = { search: query, use_minimum_char_limit: false } + + if current_user && filters[:autocomplete] + if Feature.enabled?(:users_search_scoped_to_authorized_namespaces_basic_search, current_user) + params[:by_membership] = true + elsif Feature.enabled?(:users_search_scoped_to_authorized_namespaces_basic_search_by_ids, current_user) + params[:group_member_source_ids] = current_user_authorized_group_ids + params[:project_member_source_ids] = current_user_authorized_project_ids + end + end + + UsersFinder.new(current_user, params).execute end # highlighting is only performed by Elasticsearch backed results @@ -266,6 +277,20 @@ def issuable_params end end + def current_user_authorized_group_ids + GroupsFinder + .new(current_user, { all_available: false }) + .execute + .pluck("#{Group.table_name}.#{Group.primary_key}") # rubocop: disable CodeReuse/ActiveRecord -- need to find ids + end + + def current_user_authorized_project_ids + ProjectsFinder + .new(current_user: current_user, params: { non_public: true }) + .execute + .pluck_primary_key + end + # rubocop: disable CodeReuse/ActiveRecord def limited_count(relation) relation.reorder(nil).limit(count_limit).size diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 39ad4f5c7662285dbdf9bcf6b3efe750bcce15a3..73ff03c6da411fc27fb3026882e66fa3ed02618e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -16881,6 +16881,9 @@ msgstr "" msgid "Copy file path" msgstr "" +msgid "Copy file permalink" +msgstr "" + msgid "Copy image URL" msgstr "" diff --git a/spec/features/search/user_searches_for_code_spec.rb b/spec/features/search/user_searches_for_code_spec.rb index 48161e675dd84771ea098976e9393de6416f99c9..ae861817234fe31cb5a53596b13ae7ebd4f69e8f 100644 --- a/spec/features/search/user_searches_for_code_spec.rb +++ b/spec/features/search/user_searches_for_code_spec.rb @@ -7,7 +7,13 @@ include ListboxHelpers let_it_be(:user) { create(:user) } - let_it_be_with_reload(:project) { create(:project, :repository, namespace: user.namespace) } + let_it_be_with_reload(:project) do + # This helps with some of the test flakiness. + project = create(:project, :repository, namespace: user.namespace) + project.repository.root_ref + project.repository.ls_files('master') + project + end context 'when signed in' do before do diff --git a/spec/finders/users_finder_spec.rb b/spec/finders/users_finder_spec.rb index a766923a69ac3442b2093ddcee853ed2a7ac0d4b..770d7d6aeaed7088fa2e249987bd8346cce1e2ab 100644 --- a/spec/finders/users_finder_spec.rb +++ b/spec/finders/users_finder_spec.rb @@ -149,6 +149,77 @@ users = described_class.new(user, admins: true).execute expect(users).to contain_exactly(user, normal_user, external_user, admin_user, unconfirmed_user, omniauth_user, internal_user, project_bot, service_account_user) end + + context 'when filtering by_membership' do + let_it_be(:group_user) { create(:user) } + let_it_be(:project_user) { create(:user) } + let_it_be(:group) { create(:group, developers: [user]) } + let_it_be(:project) { create(:project, developers: [user]) } + + subject(:users) { described_class.new(user, by_membership: true).execute } + + it 'includes the user and project owner' do + expect(users).to contain_exactly(user, project.owner) + end + + it 'includes users who are members of the user groups' do + group.add_developer(group_user) + + expect(users).to contain_exactly(user, project.owner, group_user) + end + + it 'includes users who are members of the user projects' do + project.add_developer(project_user) + + expect(users).to contain_exactly(user, project.owner, project_user) + end + end + + context 'when filtering by_member_source_ids' do + let_it_be(:group_user) { create(:user) } + let_it_be(:project_user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project) } + + it 'filters by group membership' do + group.add_developer(group_user) + + users = described_class.new(user, group_member_source_ids: [group.id]).execute + + expect(users).to contain_exactly(group_user) + end + + it 'filters by project membership' do + project.add_developer(project_user) + + users = described_class.new(user, project_member_source_ids: [project.id]).execute + + expect(users).to contain_exactly(project_user, project.owner) + end + + it 'filters by group and project membership' do + group.add_developer(group_user) + project.add_developer(project_user) + + users = described_class + .new(user, group_member_source_ids: [group.id], project_member_source_ids: [project.id]) + .execute + + expect(users).to contain_exactly(group_user, project_user, project.owner) + end + + it 'does not include members not part of the filtered group' do + users = described_class.new(user, group_member_source_ids: [group.id]).execute + + expect(users).not_to include(group_user) + end + + it 'does not include members not part of the filtered project' do + users = described_class.new(user, project_member_source_ids: [project.id]).execute + + expect(users).not_to include(project_user) + end + end end shared_examples 'executes users finder as admin' do diff --git a/spec/frontend/behaviors/shortcuts/shortcuts_blob_spec.js b/spec/frontend/behaviors/shortcuts/shortcuts_blob_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..3bb992636ee0753061ba2e9bd01de98c90676d2a --- /dev/null +++ b/spec/frontend/behaviors/shortcuts/shortcuts_blob_spec.js @@ -0,0 +1,56 @@ +import ShortcutsBlob from '~/behaviors/shortcuts/shortcuts_blob'; +import { PROJECT_FILES_GO_TO_PERMALINK } from '~/behaviors/shortcuts/keybindings'; +import { moveToFilePermalink } from '~/blob/utils'; + +describe('ShortcutsBlob', () => { + const shortcuts = { + add: jest.fn(), + }; + + const init = () => { + return new ShortcutsBlob(shortcuts); + }; + + beforeEach(() => { + shortcuts.add.mockClear(); + window.gon = {}; + }); + + describe('constructor', () => { + describe('when shortcuts should be added', () => { + it('adds the permalink shortcut when gon.features is undefined', () => { + init(); + + expect(shortcuts.add).toHaveBeenCalledWith( + PROJECT_FILES_GO_TO_PERMALINK, + moveToFilePermalink, + ); + }); + + it('adds shortcuts when blobOverflowMenu is false', () => { + window.gon.features = { + blobOverflowMenu: false, + }; + + init(); + + expect(shortcuts.add).toHaveBeenCalledWith( + PROJECT_FILES_GO_TO_PERMALINK, + moveToFilePermalink, + ); + }); + }); + + describe('when shortcuts should not be added', () => { + it('does not add shortcuts when blobOverflowMenu is true', () => { + window.gon.features = { + blobOverflowMenu: true, + }; + + init(); + + expect(shortcuts.add).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js index 4ec1e01e55326eebc133ca7319131a8890ee537f..558959b38e24c61a0078d4445a33214ee578af4d 100644 --- a/spec/frontend/search/mock_data.js +++ b/spec/frontend/search/mock_data.js @@ -220,7 +220,7 @@ export const MOCK_DATA_FOR_NAVIGATION_ACTION_MUTATION = { label: 'Projects', scope: 'projects', link: '/search?scope=projects&search=et', - count_link: null, + count_link: '/search/count?scope=projects&search=et', }, }; diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js index c92600e50aafdc679f1513539765841dddf87561..ba98fc85298b47d8f2caa7bffded7fda08d3c6b3 100644 --- a/spec/frontend/search/store/actions_spec.js +++ b/spec/frontend/search/store/actions_spec.js @@ -1,6 +1,9 @@ import MockAdapter from 'axios-mock-adapter'; import { mapValues } from 'lodash'; +// rspec spec/frontend/fixtures/search_navigation.rb to generate this file +import noActiveItems from 'test_fixtures/search_navigation/no_active_items.json'; import testAction from 'helpers/vuex_action_helper'; +import { setUrlParams, updateHistory } from '~/lib/utils/url_utility'; import Api from '~/api'; import { createAlert } from '~/alert'; import * as logger from '~/lib/logger'; @@ -45,6 +48,17 @@ jest.mock('~/lib/logger', () => ({ logError: jest.fn(), })); +jest.mock('~/lib/utils/url_utility', () => { + const urlUtility = jest.requireActual('~/lib/utils/url_utility'); + + return { + __esModule: true, + ...urlUtility, + setUrlParams: jest.fn(() => 'mocked-new-url'), + updateHistory: jest.fn(), + }; +}); + describe('Global Search Store Actions', () => { let mock; let state; @@ -159,41 +173,112 @@ describe('Global Search Store Actions', () => { }); }); - describe.each` - payload | isDirty | isDirtyMutation - ${{ key: SIDEBAR_PARAMS[0], value: 'test' }} | ${false} | ${[{ type: types.SET_SIDEBAR_DIRTY, payload: false }]} - ${{ key: SIDEBAR_PARAMS[0], value: 'test' }} | ${true} | ${[{ type: types.SET_SIDEBAR_DIRTY, payload: true }]} - ${{ key: SIDEBAR_PARAMS[1], value: 'test' }} | ${false} | ${[{ type: types.SET_SIDEBAR_DIRTY, payload: false }]} - ${{ key: SIDEBAR_PARAMS[1], value: 'test' }} | ${true} | ${[{ type: types.SET_SIDEBAR_DIRTY, payload: true }]} - ${{ key: 'non-sidebar', value: 'test' }} | ${false} | ${[]} - ${{ key: 'non-sidebar', value: 'test' }} | ${true} | ${[]} - `('setQuery', ({ payload, isDirty, isDirtyMutation }) => { - describe(`when filter param is ${payload.key} and utils.isSidebarDirty returns ${isDirty}`, () => { - const expectedMutations = [{ type: types.SET_QUERY, payload }].concat(isDirtyMutation); + describe('setQuery', () => { + describe('when search type is zoekt and scope is blob with zoektMultimatchFrontend feature enabled', () => { + const payload = { key: 'some-key', value: 'some-value' }; + let originalGon; + let commit; + let fetchSidebarCountSpy; beforeEach(() => { - storeUtils.isSidebarDirty = jest.fn().mockReturnValue(isDirty); + originalGon = window.gon; + commit = jest.fn(); + + fetchSidebarCountSpy = jest + .spyOn(actions, 'fetchSidebarCount') + .mockImplementation(() => Promise.resolve()); + + window.gon = { features: { zoektMultimatchFrontend: true } }; + storeUtils.isSidebarDirty = jest.fn().mockReturnValue(false); + + state = createState({ + query: { ...MOCK_QUERY, search: 'test-search' }, + navigation: { ...MOCK_NAVIGATION }, + searchType: 'zoekt', + }); }); - it(`should dispatch the correct mutations`, () => { - return testAction({ action: actions.setQuery, payload, state, expectedMutations }); + afterEach(() => { + window.gon = originalGon; + fetchSidebarCountSpy.mockRestore(); }); - }); - }); - describe.each` - payload - ${{ key: REGEX_PARAM, value: true }} - ${{ key: REGEX_PARAM, value: { random: 'test' } }} - `('setQuery', ({ payload }) => { - describe(`when query param is ${payload.key}`, () => { - beforeEach(() => { - storeUtils.setDataToLS = jest.fn(); - actions.setQuery({ state, commit: jest.fn() }, payload); + it('should update URL, document title, and history', async () => { + const getters = { currentScope: 'blobs' }; + + await actions.setQuery({ state, commit, getters }, payload); + + expect(setUrlParams).toHaveBeenCalledWith( + { ...state.query }, + window.location.href, + false, + true, + ); + + expect(document.title).toBe(state.query.search); + + expect(updateHistory).toHaveBeenCalledWith({ + state: state.query, + title: state.query.search, + url: 'mocked-new-url', + replace: false, + }); + }); + + it('does not update URL or fetch sidebar counts when conditions are not met', async () => { + let getters = { currentScope: 'blobs' }; + state.searchType = 'not-zoekt'; + + await actions.setQuery({ state, commit, getters }, payload); + + expect(setUrlParams).not.toHaveBeenCalled(); + expect(updateHistory).not.toHaveBeenCalled(); + expect(fetchSidebarCountSpy).not.toHaveBeenCalled(); + + setUrlParams.mockClear(); + updateHistory.mockClear(); + fetchSidebarCountSpy.mockClear(); + + state.searchType = 'zoekt'; + getters = { currentScope: 'not-blobs' }; + + await actions.setQuery({ state, commit, getters }, payload); + + expect(setUrlParams).not.toHaveBeenCalled(); + expect(updateHistory).not.toHaveBeenCalled(); + expect(fetchSidebarCountSpy).not.toHaveBeenCalled(); + + setUrlParams.mockClear(); + updateHistory.mockClear(); + fetchSidebarCountSpy.mockClear(); + + getters = { currentScope: 'blobs' }; + window.gon.features.zoektMultimatchFrontend = false; + + await actions.setQuery({ state, commit, getters }, payload); + + expect(setUrlParams).not.toHaveBeenCalled(); + expect(updateHistory).not.toHaveBeenCalled(); + expect(fetchSidebarCountSpy).not.toHaveBeenCalled(); }); + }); + + describe.each` + payload + ${{ key: REGEX_PARAM, value: true }} + ${{ key: REGEX_PARAM, value: { random: 'test' } }} + `('setQuery with REGEX_PARAM', ({ payload }) => { + describe(`when query param is ${payload.key}`, () => { + beforeEach(() => { + storeUtils.setDataToLS = jest.fn(); + window.gon = { features: { zoektMultimatchFrontend: false } }; + const getters = { currentScope: 'not-blobs' }; + actions.setQuery({ state, commit: jest.fn(), getters }, payload); + }); - it(`setsItem in local storage`, () => { - expect(storeUtils.setDataToLS).toHaveBeenCalledWith(LS_REGEX_HANDLE, expect.anything()); + it(`setsItem in local storage`, () => { + expect(storeUtils.setDataToLS).toHaveBeenCalledWith(LS_REGEX_HANDLE, expect.anything()); + }); }); }); }); @@ -201,7 +286,12 @@ describe('Global Search Store Actions', () => { describe('applyQuery', () => { beforeEach(() => { setWindowLocation('https://test/'); - jest.spyOn(urlUtils, 'visitUrl').mockReturnValue({}); + jest.spyOn(urlUtils, 'visitUrl').mockImplementation(() => {}); + jest + .spyOn(urlUtils, 'setUrlParams') + .mockReturnValue( + 'https://test/?scope=issues&state=all&group_id=1&language%5B%5D=C&language%5B%5D=JavaScript&label_name%5B%5D=Aftersync&label_name%5B%5D=Brist&search=*', + ); }); it('calls visitUrl and setParams with the state.query', async () => { @@ -355,17 +445,29 @@ describe('Global Search Store Actions', () => { }); }); - describe('fetchSidebarCount uses wild card seach', () => { + describe('fetchSidebarCount uses wild card search', () => { beforeEach(() => { - state.navigation = MOCK_NAVIGATION; - state.urlQuery.search = ''; + state.navigation = noActiveItems; + state.query = { search: '' }; + state.urlQuery = { search: '' }; + + jest.spyOn(urlUtils, 'setUrlParams').mockImplementation((params) => { + return `http://test.host/search/count?search=${params.search || '*'}`; + }); + + storeUtils.skipBlobESCount = jest.fn().mockReturnValue(true); + + mock.onGet().reply(HTTP_STATUS_OK, MOCK_ENDPOINT_RESPONSE); }); it('should use wild card', async () => { - await testAction({ action: actions.fetchSidebarCount, state, expectedMutations: [] }); - expect(mock.history.get[0].url).toBe('http://test.host/search/count?scope=projects&search=*'); - expect(mock.history.get[3].url).toBe( - 'http://test.host/search/count?scope=merge_requests&search=*', + const commit = jest.fn(); + + await actions.fetchSidebarCount({ commit, state }); + + expect(urlUtils.setUrlParams).toHaveBeenCalledWith( + expect.objectContaining({ search: '*' }), + expect.anything(), ); }); }); @@ -409,16 +511,16 @@ describe('Global Search Store Actions', () => { { payload: { key: 'label_name', - value: ['Aftersync', 'Brist'], + value: ['Aftersync'], }, type: 'SET_QUERY', }, { - payload: true, + payload: false, type: 'SET_SIDEBAR_DIRTY', }, ]; - return testAction(actions.closeLabel, { key: '60' }, state, expectedResult, []); + return testAction(actions.closeLabel, { title: 'Brist' }, state, expectedResult, []); }); }); diff --git a/spec/frontend/search/store/utils_spec.js b/spec/frontend/search/store/utils_spec.js index 014f963002e1c2626fe37d316b1d085962d586b9..ffb0c80ed06e083bd407ef61d8dcc75c9b1e927e 100644 --- a/spec/frontend/search/store/utils_spec.js +++ b/spec/frontend/search/store/utils_spec.js @@ -1,3 +1,4 @@ +// rspec spec/frontend/fixtures/search_navigation.rb to generate these files import subItemActive from 'test_fixtures/search_navigation/sub_item_active.json'; import noActiveItems from 'test_fixtures/search_navigation/no_active_items.json'; import partialNavigationActive from 'test_fixtures/search_navigation/partial_navigation_active.json'; @@ -17,6 +18,7 @@ import { injectRegexSearch, scopeCrawler, skipBlobESCount, + buildDocumentTitle, } from '~/search/store/utils'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; @@ -447,4 +449,46 @@ describe('Global Search Store Utils', () => { expect(skipBlobESCount(state, SCOPE_BLOB)).toBe(false); }); }); + + describe('buildDocumentTitle', () => { + const SEARCH_WINDOW_TITLE = `Search`; // Make sure this matches your actual constant + let originalTitle; + + beforeEach(() => { + originalTitle = document.title; + }); + + afterEach(() => { + document.title = originalTitle; + }); + + it('returns original title when document title does not include search title', () => { + document.title = 'GitLab'; + expect(buildDocumentTitle('test')).toBe('test'); + }); + + it('prepends new title when document title starts with search title', () => { + document.title = `${SEARCH_WINDOW_TITLE} · GitLab`; + const result = buildDocumentTitle('test'); + expect(result).toBe(`test · ${SEARCH_WINDOW_TITLE} · GitLab`); + }); + + it('prepends new title when document title starts with dot and search title', () => { + document.title = ` · ${SEARCH_WINDOW_TITLE} · GitLab`; + const result = buildDocumentTitle('test'); + expect(result).toBe(`test · ${SEARCH_WINDOW_TITLE} · GitLab`); + }); + + it('replaces title before search title with new title', () => { + document.title = `Issues · ${SEARCH_WINDOW_TITLE} · GitLab`; + const result = buildDocumentTitle('test'); + expect(result).toBe(`test · ${SEARCH_WINDOW_TITLE} · GitLab`); + }); + + it('handles complex titles correctly', () => { + document.title = `Something · With · Dots · ${SEARCH_WINDOW_TITLE} · GitLab`; + const result = buildDocumentTitle('test'); + expect(result).toBe(`test · ${SEARCH_WINDOW_TITLE} · GitLab`); + }); + }); }); diff --git a/spec/frontend/search/topbar/components/app_spec.js b/spec/frontend/search/topbar/components/app_spec.js index ca4023086db3033cb7caeb0d17cc55d8e508b373..da2d3e12ffc66f71038e518dc622c88d92e976ee 100644 --- a/spec/frontend/search/topbar/components/app_spec.js +++ b/spec/frontend/search/topbar/components/app_spec.js @@ -229,4 +229,48 @@ describe('GlobalSearchTopbar', () => { }); }); }); + + describe('search computed property setter', () => { + describe.each` + FF | scope | searchType | debounced + ${{ zoektMultimatchFrontend: true }} | ${'blobs'} | ${'zoekt'} | ${true} + ${{ zoektMultimatchFrontend: false }} | ${'blobs'} | ${'zoekt'} | ${false} + ${{ zoektMultimatchFrontend: true }} | ${'issues'} | ${'zoekt'} | ${false} + ${{ zoektMultimatchFrontend: true }} | ${'blobs'} | ${'advanced'} | ${false} + `( + 'when isMultiMatch is $debounced (FF: $FF, scope: $scope, searchType: $searchType)', + ({ FF, scope, searchType, debounced }) => { + beforeEach(() => { + getterSpies.currentScope = jest.fn(() => scope); + actionSpies.setQuery.mockClear(); + + createComponent({ + featureFlag: FF, + initialState: { searchType }, + }); + + wrapper.vm.debouncedSetQuery = jest.fn(); + }); + + it(`${debounced ? 'calls debouncedSetQuery' : 'calls setQuery directly'}`, () => { + findGlSearchBox().vm.$emit('input', 'new search value'); + + if (debounced) { + expect(actionSpies.setQuery).not.toHaveBeenCalled(); + } else { + expect(actionSpies.setQuery).toHaveBeenCalled(); + + const lastCallArgs = actionSpies.setQuery.mock.calls[0]; + const payload = lastCallArgs[lastCallArgs.length - 1]; + expect(payload).toEqual( + expect.objectContaining({ + key: 'search', + value: 'new search value', + }), + ); + } + }); + }, + ); + }); }); diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb index 36544a75648d5d4503eb95e98c2c9345e8f5e9d4..782d49f4968233972321e78bbf910a28c019fca1 100644 --- a/spec/helpers/search_helper_spec.rb +++ b/spec/helpers/search_helper_spec.rb @@ -63,8 +63,10 @@ def simple_sanitize(str) shared_examples 'for users' do let_it_be(:another_user) { create(:user, name: 'Jane Doe') } let(:term) { 'jane' } + let_it_be(:project) { create(:project, developers: user) } it 'returns users matching the term' do + project.add_developer(another_user) result = search_autocomplete_opts(term) expect(result.size).to eq(1) expect(result.first[:id]).to eq(another_user.id) @@ -97,21 +99,29 @@ def simple_sanitize(str) it 'includes users with matching public emails' do public_email_user + project.add_developer(public_email_user) + expect(ids).to include(public_email_user.id) end it 'includes users in forbidden states' do banned_user + project.add_developer(banned_user) + expect(ids).to include(banned_user.id) end it 'includes users without matching public emails but with matching private emails' do private_email_user + project.add_developer(private_email_user) + expect(ids).to include(private_email_user.id) end it 'includes users matching on secondary email' do secondary_email + project.add_developer(user_with_other_email) + expect(ids).to include(secondary_email.user_id) end end @@ -123,21 +133,29 @@ def simple_sanitize(str) it 'includes users with matching public emails' do public_email_user + project.add_developer(public_email_user) + expect(ids).to include(public_email_user.id) end it 'does not include users in forbidden states' do banned_user + project.add_developer(banned_user) + expect(ids).not_to include(banned_user.id) end it 'does not include users without matching public emails but with matching private emails' do private_email_user + project.add_developer(private_email_user) + expect(ids).not_to include(private_email_user.id) end it 'does not include users matching on secondary email' do secondary_email + project.add_developer(secondary_email) + expect(ids).not_to include(secondary_email.user_id) end end @@ -146,6 +164,12 @@ def simple_sanitize(str) context 'with limiting' do let_it_be(:users) { create_list(:user, 6, name: 'Jane Doe') } + before do + users.each do |user| + project.add_developer(user) + end + end + it 'only returns the first 5 users' do result = search_autocomplete_opts(term) expect(result.size).to eq(5) diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index b15af98e1d9b550a8c4ec0651b329855d5753026..42971c087fc7f41d724117cf86eae117dbd01a73 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -7,7 +7,7 @@ include SearchHelpers using RSpec::Parameterized::TableSyntax - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, username: 'foobar') } let_it_be(:project) { create(:project, name: 'foo') } let_it_be(:issue) { create(:issue, project: project, title: 'foo') } let_it_be(:milestone) { create(:milestone, project: project, title: 'foo') } @@ -313,18 +313,166 @@ end describe '#users' do + subject(:user_search_result) { results.objects('users') } + + let_it_be(:another_user) { create(:user, username: 'barfoo') } + let_it_be(:group) { create(:group) } + it 'does not call the UsersFinder when the current_user is not allowed to read users list' do allow(Ability).to receive(:allowed?).and_return(false) - expect(UsersFinder).not_to receive(:new).with(user, { search: 'foo', use_minimum_char_limit: false }).and_call_original + expect(UsersFinder).not_to receive(:new) - results.objects('users') + user_search_result end it 'calls the UsersFinder' do - expect(UsersFinder).to receive(:new).with(user, { search: 'foo', use_minimum_char_limit: false }).and_call_original + expected_params = { + search: 'foo', + use_minimum_char_limit: false + } + + expect(UsersFinder).to receive(:new).with(user, expected_params).and_call_original + + user_search_result + end + + context 'when the autocomplete filter is added' do + let(:filters) { { autocomplete: true } } + + shared_examples 'returns users' do + it 'returns the current_user since they match the query' do + expect(user_search_result).to match_array(user) + end + + context 'when another user belongs to a project the current_user belongs to' do + before do + project.add_developer(another_user) + end + + it 'includes the other user' do + expect(user_search_result).to match_array([user, another_user]) + end + end + + context 'when another user belongs to a group' do + before do + group.add_developer(another_user) + end + + it 'does not include the other user' do + expect(user_search_result).not_to include(another_user) + end + + context 'when the current_user also belongs to that group' do + before do + group.add_developer(user) + end + + it 'includes the other user' do + expect(user_search_result).to match_array([user, another_user]) + end + end + + context 'when the current_user belongs to a parent of the group' do + let_it_be(:parent_group) { create(:group) } + let_it_be(:group) { create(:group, parent: parent_group) } + + before do + parent_group.add_developer(user) + end + + it 'includes the other user' do + expect(user_search_result).to match_array([user, another_user]) + end + end + + context 'when the current_user belongs to a group that is shared by the group' do + let_it_be_with_reload(:shared_with_group) { create(:group) } + let_it_be_with_reload(:group_group_link) do + create( + :group_group_link, + group_access: ::Gitlab::Access::GUEST, + shared_group: group, + shared_with_group: shared_with_group + ) + end + + before do + shared_with_group.add_developer(user) + end + + it 'includes the other user' do + expect(user_search_result).to match_array([user, another_user]) + end + end + + context 'when the current_user belongs to a child of the group' do + let_it_be(:child_group) { create(:group, parent: group) } + + before do + child_group.add_developer(user) + end + + it 'includes the other user' do + expect(user_search_result).to match_array([user, another_user]) + end + end + end + + context 'when another user is a guest of a private group' do + let_it_be(:private_group) { create(:group, :private) } + + before do + private_group.add_guest(another_user) + end - results.objects('users') + it 'does not include the other user' do + expect(user_search_result).to match_array(user) + end + + context 'when the current_user is a guest of the private group' do + before do + private_group.add_guest(user) + end + + it 'includes the other user' do + expect(user_search_result).to match_array([user, another_user]) + end + end + + context 'when the current_user is a guest of the public parent of the private group' do + let_it_be(:public_parent_group) { create(:group, :public) } + let_it_be(:private_group) { create(:group, :private, parent: public_parent_group) } + + before do + public_parent_group.add_guest(user) + end + + it 'includes the other user' do + expect(user_search_result).to match_array([user, another_user]) + end + end + end + end + + context 'when users_search_scoped_to_authorized_namespaces_basic_search is enabled' do + before do + stub_feature_flags(users_search_scoped_to_authorized_namespaces_basic_search: true) + stub_feature_flags(users_search_scoped_to_authorized_namespaces_basic_search_by_ids: false) + end + + include_examples 'returns users' + end + + context 'when users_search_scoped_to_authorized_namespaces_basic_search_by_ids is enabled' do + before do + stub_feature_flags(users_search_scoped_to_authorized_namespaces_basic_search_by_ids: true) + stub_feature_flags(users_search_scoped_to_authorized_namespaces_basic_search: false) + end + + include_examples 'returns users' + end end end end diff --git a/spec/support/helpers/search_helpers.rb b/spec/support/helpers/search_helpers.rb index b9151964628254a4b34024202cfe065d84fd82f6..d4bf82f536a67b3e8f0b6ba7c8b840643527439f 100644 --- a/spec/support/helpers/search_helpers.rb +++ b/spec/support/helpers/search_helpers.rb @@ -9,15 +9,16 @@ def fill_in_search(text) end def submit_search(query) - # Forms directly on the search page if page.has_css?('.search-page-form') search_form = '.search-page-form' - # Open search modal from super sidebar + else find_by_testid('super-sidebar-search-button').click search_form = '#super-sidebar-search-modal' end + wait_for_all_requests + page.within(search_form) do field = find_field('search') field.click