From 4bb16efb97091900d14eddff8566a37ebcfa5cdc Mon Sep 17 00:00:00 2001 From: Siddharth Dungarwal <sdungarwal@gitlab.com> Date: Thu, 26 Sep 2024 16:07:02 +0000 Subject: [PATCH] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: DANGER_GITLAB_API_TOKEN <project_278964_bot_445da1b60fc7336b6b6776383134d10f@noreply.gitlab.com> --- app/helpers/search_helper.rb | 5 + app/views/search/_results_list.html.haml | 3 + doc/development/advanced_search.md | 202 ++++++++++++++++++ .../views/search/results/_work_item.html.haml | 20 ++ .../search_epics_uses_work_items_index.yml | 9 + ee/lib/gitlab/elastic/search_results.rb | 39 +++- ee/lib/search/elastic/filters.rb | 86 ++++++++ .../elastic/work_item_group_query_builder.rb | 20 ++ .../search/elastic/work_item_query_builder.rb | 15 +- .../search/elastic/group_search_spec.rb | 8 +- .../search/user_searches_for_epics_spec.rb | 45 +++- .../elastic/group_search_results_spec.rb | 56 +++++ ee/spec/lib/search/elastic/filters_spec.rb | 176 +++++++++++++++ .../services/search/global_service_spec.rb | 32 ++- ee/spec/services/search/group_service_spec.rb | 57 ++++- locale/gitlab.pot | 3 + 16 files changed, 751 insertions(+), 25 deletions(-) create mode 100644 ee/app/views/search/results/_work_item.html.haml create mode 100644 ee/config/feature_flags/development/search_epics_uses_work_items_index.yml create mode 100644 ee/lib/search/elastic/work_item_group_query_builder.rb diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 528c1b2cc9d77..4465ee587fa2b 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -248,6 +248,11 @@ def search_scope end end + def should_show_work_items_as_epics_in_results? + ::Feature.enabled?(:search_epics_uses_work_items_index, current_user) && + ::Elastic::DataMigrationService.migration_has_finished?(:backfill_work_items) + end + def should_show_zoekt_results?(_scope, _search_type) false end diff --git a/app/views/search/_results_list.html.haml b/app/views/search/_results_list.html.haml index 40e15e33e49c2..c5365bd03441b 100644 --- a/app/views/search/_results_list.html.haml +++ b/app/views/search/_results_list.html.haml @@ -20,6 +20,9 @@ - if @scope == 'projects' .term = render 'shared/projects/list', projects: @search_objects, pipeline_status: false + - elsif @scope === 'epics' && should_show_work_items_as_epics_in_results? + - @search_objects.each_with_index do |work_item, index| + = render partial: "search/results/work_item", locals: { work_item: work_item, index: index } - elsif @scope === 'users' %table.table.b-table.gl-table.b-table-stacked-md{ role: 'table' } %thead diff --git a/doc/development/advanced_search.md b/doc/development/advanced_search.md index f227f4112fe1d..49311fa22daae 100644 --- a/doc/development/advanced_search.md +++ b/doc/development/advanced_search.md @@ -450,6 +450,43 @@ Requires `type` field. Query with `doc_type` in options. } ``` +##### `by_group_level_confidentiality` + +Requires `current_user` and `group_ids` fields. Query based on the permissions to user to read confidential group entities. + +```json +{ + "bool": { + "must": [ + { + "term": { + "confidential": { + "value": true, + "_name": "confidential:true" + } + } + }, + { + "terms": { + "namespace_id": [ + 1 + ], + "_name": "groups:can:read_confidential_work_items" + } + } + ] + }, + "should": { + "term": { + "confidential": { + "value": false, + "_name": "confidential:false" + } + } + } +} +``` + ##### `by_confidentiality` Requires `confidential`, `author_id`, `assignee_id`, `project_id` fields. Query with `confidential` in options. @@ -685,6 +722,171 @@ Requires `source_branch` field. Query with `source_branch` or `not_source_branch } ``` +##### `by_group_level_authorization` + +Requires `current_user`, `group_ids`, `traversal_id`, `search_level` fields. Query with `search_level` and +filter on `namespace_visibility_level` based on permissions user has for each group. + +NOTE: +Examples are shown for a logged in user. The JSON may be different for users with authorizations, admins, external, or anonymous users + +###### global + +```json +{ + "bool": { + "should": [ + { + "bool": { + "filter": [ + { + "term": { + "namespace_visibility_level": { + "value": 20, + "_name": "filters:namespace_visibility_level:public" + } + } + } + ] + } + }, + { + "bool": { + "filter": [ + { + "term": { + "namespace_visibility_level": { + "value": 10, + "_name": "filters:namespace_visibility_level:internal" + } + } + } + ] + } + }, + { + "bool": { + "filter": [ + { + "term": { + "namespace_visibility_level": { + "value": 0, + "_name": "filters:namespace_visibility_level:private" + } + } + }, + { + "terms": { + "namespace_id": [ + 33, + 22 + ] + } + } + ] + } + } + ], + "minimum_should_match": 1 + } +} +``` + +###### group + +```json +[ + { + "bool": { + "_name": "filters:level:group", + "minimum_should_match": 1, + "should": [ + { + "prefix": { + "traversal_ids": { + "_name": "filters:level:group:ancestry_filter:descendants", + "value": "22-" + } + } + } + ] + } + }, + { + "bool": { + "should": [ + { + "bool": { + "filter": [ + { + "term": { + "namespace_visibility_level": { + "value": 20, + "_name": "filters:namespace_visibility_level:public" + } + } + } + ] + } + }, + { + "bool": { + "filter": [ + { + "term": { + "namespace_visibility_level": { + "value": 10, + "_name": "filters:namespace_visibility_level:internal" + } + } + } + ] + } + }, + { + "bool": { + "filter": [ + { + "term": { + "namespace_visibility_level": { + "value": 0, + "_name": "filters:namespace_visibility_level:private" + } + } + }, + { + "terms": { + "namespace_id": [ + 22 + ] + } + } + ] + } + } + ], + "minimum_should_match": 1 + } + }, + { + "bool": { + "_name": "filters:level:group", + "minimum_should_match": 1, + "should": [ + { + "prefix": { + "traversal_ids": { + "_name": "filters:level:group:ancestry_filter:descendants", + "value": "22-" + } + } + } + ] + } + } +] +``` + ##### `by_search_level_and_membership` Requires `project_id` and `traversal_id` fields. Supports feature `*_access_level` fields. Query with `search_level` diff --git a/ee/app/views/search/results/_work_item.html.haml b/ee/app/views/search/results/_work_item.html.haml new file mode 100644 index 0000000000000..ad10d2d632c14 --- /dev/null +++ b/ee/app/views/search/results/_work_item.html.haml @@ -0,0 +1,20 @@ +- index = local_assigns.fetch(:index, nil) +- work_item = local_assigns.fetch(:work_item, nil) +- position = index + 1 + +.search-result-row.gl-mt-5 + %span.gl-flex.gl-items-center + - if work_item.closed? + = gl_badge_tag _('Closed'), variant: :info + - else + = gl_badge_tag _('Open'), variant: :success + = sprite_icon('eye-slash', css_class: 'gl-text-secondary gl-ml-2') if work_item.confidential? + = link_to group_work_item_path(work_item.namespace, work_item), data: { event_tracking: 'click_search_result', event_label: @scope, event_value: position, event_property: @search_term }, class: 'gl-w-full' do + %span.term.str-truncated.gl-font-bold.gl-ml-2= work_item.title + .gl-text-secondary.gl-mb-3.gl-text-sm + = sprintf(s_('%{group_name}&%{work_item_iid} · created %{work_item_created} by %{author}'), { group_name: work_item.namespace.full_name, work_item_iid: work_item.iid, work_item_created: time_ago_with_tooltip(work_item.created_at, placement: 'bottom'), author: link_to_member(work_item.author, avatar: false) }).html_safe + · + = _('updated %{time_ago}').html_safe % { time_ago: time_ago_with_tooltip(work_item.updated_at, placement: 'bottom') } + - if work_item.description.present? + .description.term.col-sm-10.gl-px-0.gl-text-sm + = truncate(work_item.description, length: 200) diff --git a/ee/config/feature_flags/development/search_epics_uses_work_items_index.yml b/ee/config/feature_flags/development/search_epics_uses_work_items_index.yml new file mode 100644 index 0000000000000..37a9dcc578a0b --- /dev/null +++ b/ee/config/feature_flags/development/search_epics_uses_work_items_index.yml @@ -0,0 +1,9 @@ +--- +name: search_epics_uses_work_items_index +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/480118 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/165827 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/487755 +milestone: '17.5' +type: development +group: group::global search +default_enabled: false diff --git a/ee/lib/gitlab/elastic/search_results.rb b/ee/lib/gitlab/elastic/search_results.rb index 84cf5e0ab804a..27b94fd9f5cbc 100644 --- a/ee/lib/gitlab/elastic/search_results.rb +++ b/ee/lib/gitlab/elastic/search_results.rb @@ -57,7 +57,7 @@ def objects(scope, page: 1, per_page: DEFAULT_PER_PAGE, preload_method: nil) when 'notes' eager_load(notes, page, per_page, preload_method, project: [:invited_groups, :route, :namespace]) when 'epics' - eager_load(epics, page, per_page, preload_method, nil) + epics(page: page, per_page: per_page, preload_method: preload_method) when 'blobs' blobs(page: page, per_page: per_page, preload_method: preload_method) when 'wiki_blobs' @@ -333,6 +333,16 @@ def scope_options(scope) # Otherwise it will ignore project_ids and return milestones # from projects with milestones disabled. base_options.merge({ features: [:issues, :merge_requests] }, filters.slice(:include_archived)) + when :epics + if work_item_index_available_for_searching_epics? + work_item_scope_options.merge( + not_work_item_type_ids: nil, + klass: WorkItem, + work_item_type_ids: [::WorkItems::Type.find_by_name(::WorkItems::Type::TYPE_NAMES[:epic]).id] + ) + else + base_options + end when :users base_options.merge(admin: current_user&.admin?, routing_disabled: true) # rubocop:disable Cop/UserAdmin when :blobs @@ -373,7 +383,7 @@ def projects(count_only: false) def issues(page: 1, per_page: DEFAULT_PER_PAGE, count_only: false, preload_method: nil) strong_memoize(memoize_key('issues', count_only: count_only)) do - if work_item_index_available_for_searching? + if work_item_index_available_for_searching_issues? options = scope_options(:work_items) .merge(count_only: count_only, per_page: per_page, page: page, preload_method: preload_method) search_query = ::Search::Elastic::WorkItemQueryBuilder.build(query: query, options: options) @@ -389,7 +399,12 @@ def issues(page: 1, per_page: DEFAULT_PER_PAGE, count_only: false, preload_metho end end - def work_item_index_available_for_searching? + def work_item_index_available_for_searching_epics? + ::Feature.enabled?(:search_epics_uses_work_items_index, current_user) && + ::Elastic::DataMigrationService.migration_has_finished?(:backfill_work_items) + end + + def work_item_index_available_for_searching_issues? ::Feature.enabled?(:search_issues_uses_work_items_index, current_user) && ::Elastic::DataMigrationService.migration_has_finished?(:backfill_work_items) end @@ -406,8 +421,20 @@ def notes(count_only: false) scope_results :notes, Note, count_only: count_only end - def epics(count_only: false) - scope_results :epics, Epic, count_only: count_only + def epics(page: 1, per_page: DEFAULT_PER_PAGE, count_only: false, preload_method: nil) + unless work_item_index_available_for_searching_epics? + epics = scope_results :epics, Epic, count_only: count_only + return eager_load(epics, page, per_page, preload_method, nil) + end + + strong_memoize(memoize_key('epics', count_only: count_only)) do + options = scope_options(:epics) + .merge(count_only: count_only, per_page: per_page, page: page, preload_method: preload_method) + search_query = ::Search::Elastic::WorkItemGroupQueryBuilder.build(query: query, options: options) + ::Gitlab::Search::Client.execute_search(query: search_query, options: options) do |response| + ::Search::Elastic::ResponseMapper.new(response, options).paginated_array + end + end end def users(page: 1, per_page: DEFAULT_PER_PAGE, count_only: false, preload_method: nil) @@ -486,7 +513,7 @@ def blob_aggregations strong_memoize_attr :blob_aggregations def issue_aggregations - if work_item_index_available_for_searching? + if work_item_index_available_for_searching_issues? options = scope_options(:work_items).merge(aggregation: true) search_query = ::Search::Elastic::WorkItemQueryBuilder.build(query: query, options: options) else diff --git a/ee/lib/search/elastic/filters.rb b/ee/lib/search/elastic/filters.rb index dcf35f95a1945..a4c5c82bc6497 100644 --- a/ee/lib/search/elastic/filters.rb +++ b/ee/lib/search/elastic/filters.rb @@ -231,6 +231,82 @@ def by_label_ids(query_hash:, options:) end end + def by_group_level_confidentiality(query_hash:, options:) + user = options[:current_user] + query_hash = search_level_filter(query_hash: query_hash, options: options) + return query_hash if user.nil? || user&.can_read_all_resources? + + confidential_group_ids = group_ids_user_has_min_access_as( + access_level: ::Gitlab::Access::REPORTER, user: options[:current_user], group_ids: options[:group_ids] + ) + + return query_hash if confidential_group_ids.empty? + + context.name(:filters) do + add_filter(query_hash, :query, :bool, :filter) do + { bool: { should: [ + { + bool: { + must: [ + { term: { confidential: { value: true, _name: context.name(:confidential, :groups) } } }, + { terms: { namespace_id: confidential_group_ids, + _name: context.name(:confidential, :groups, "can_read_confidential_work_items") } } + ] + } + }, + { term: { confidential: { value: false, _name: context.name(:non_confidential, :groups) } } } + ] } } + end + end + end + + def by_group_level_authorization(query_hash:, options:) + user = options[:current_user] + query_hash = search_level_filter(query_hash: query_hash, options: options) + return query_hash if user&.can_read_all_resources? + + context.name(:filters) do + add_filter(query_hash, :query, :bool, :filter) do + visibility_filters = Search::Elastic::BoolExpr.new + visibility_filters.minimum_should_match = 1 + add_filter(visibility_filters, :should) do + { bool: { filter: [ + { term: { namespace_visibility_level: { value: ::Gitlab::VisibilityLevel::PUBLIC, + _name: context.name(:namespace_visibility_level, + :public) } } } + ] } } + end + + if user && !user.external? + add_filter(visibility_filters, :should) do + { bool: { filter: [ + { term: { namespace_visibility_level: { value: ::Gitlab::VisibilityLevel::INTERNAL, + _name: context.name(:namespace_visibility_level, + :internal) } } } + ] } } + end + authorized_group_ids = group_ids_user_has_min_access_as(access_level: ::Gitlab::Access::GUEST, + user: user, group_ids: options[:group_ids]) + + unless authorized_group_ids.empty? + add_filter(visibility_filters, :should) do + { bool: { filter: [ + { term: { namespace_visibility_level: { value: ::Gitlab::VisibilityLevel::PRIVATE, + _name: context.name(:namespace_visibility_level, + :private) } } }, + { terms: { namespace_id: authorized_group_ids } } + + ] } } + end + end + + end + + { bool: visibility_filters.to_h } + end + end + end + def by_confidentiality(query_hash:, options:) confidential = options[:confidential] user = options[:current_user] @@ -356,6 +432,16 @@ def add_filter(query_hash, *path) query_hash end + def group_ids_user_has_min_access_as(access_level:, user:, group_ids:) + finder_params = { min_access_level: access_level } + if group_ids.present? + finder_params[:filter_group_ids] = + Group.id_in(group_ids.uniq).map(&:self_and_descendants_ids).uniq + end + + ::GroupsFinder.new(user, finder_params).execute.pluck("#{Group.table_name}.#{Group.primary_key}") # rubocop:disable CodeReuse/ActiveRecord -- we need pluck only the ids from the finder + end + def scoped_project_ids(current_user, project_ids) return :any if project_ids == :any diff --git a/ee/lib/search/elastic/work_item_group_query_builder.rb b/ee/lib/search/elastic/work_item_group_query_builder.rb new file mode 100644 index 0000000000000..da7ed7d2c957b --- /dev/null +++ b/ee/lib/search/elastic/work_item_group_query_builder.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Search + module Elastic + class WorkItemGroupQueryBuilder < ::Search::Elastic::WorkItemQueryBuilder + extend ::Gitlab::Utils::Override + + private + + override :extra_options + def extra_options + super.merge({ + features: nil, + group_level_authorization: true, + group_level_confidentiality: true + }) + end + end + end +end diff --git a/ee/lib/search/elastic/work_item_query_builder.rb b/ee/lib/search/elastic/work_item_query_builder.rb index 2e4fedf57f878..55a709cfeaac7 100644 --- a/ee/lib/search/elastic/work_item_query_builder.rb +++ b/ee/lib/search/elastic/work_item_query_builder.rb @@ -24,8 +24,19 @@ def build end end - query_hash = ::Search::Elastic::Filters.by_authorization(query_hash: query_hash, options: options) - query_hash = ::Search::Elastic::Filters.by_confidentiality(query_hash: query_hash, options: options) + query_hash = if options[:group_level_authorization] + ::Search::Elastic::Filters.by_group_level_authorization(query_hash: query_hash, options: options) + else + ::Search::Elastic::Filters.by_authorization(query_hash: query_hash, options: options) + end + + query_hash = if options[:group_level_confidentiality] + ::Search::Elastic::Filters.by_group_level_confidentiality(query_hash: query_hash, + options: options) + else + ::Search::Elastic::Filters.by_confidentiality(query_hash: query_hash, options: options) + end + query_hash = ::Search::Elastic::Filters.by_state(query_hash: query_hash, options: options) query_hash = ::Search::Elastic::Filters.by_not_hidden(query_hash: query_hash, options: options) query_hash = ::Search::Elastic::Filters.by_label_ids(query_hash: query_hash, options: options) diff --git a/ee/spec/features/search/elastic/group_search_spec.rb b/ee/spec/features/search/elastic/group_search_spec.rb index b98b88dd1205f..63fe3f22871cb 100644 --- a/ee/spec/features/search/elastic/group_search_spec.rb +++ b/ee/spec/features/search/elastic/group_search_spec.rb @@ -29,7 +29,13 @@ def choose_group(group) stub_feature_flags(search_issues_uses_work_items_index: (document_type == :work_item)) project.repository.index_commits_and_blobs stub_licensed_features(epics: true, group_wikis: true) - create(:epic, group: group, title: 'chosen epic title') + stub_feature_flags(search_epics_uses_work_items_index: (document_type == :work_item)) + if document_type == :work_item # rubocop:disable RSpec/AvoidConditionalStatements -- We need to create objects based on document type. + create(:work_item, :group_level, :epic_with_legacy_epic, namespace: group, title: 'chosen epic title') + else + create(:epic, group: group, title: 'chosen epic title') + end + Sidekiq::Worker.skipping_transaction_check do [group_wiki, wiki].each do |w| w.create_page('test.md', '# term') diff --git a/ee/spec/features/search/user_searches_for_epics_spec.rb b/ee/spec/features/search/user_searches_for_epics_spec.rb index 1746c22d56249..d6dcb84a0d08e 100644 --- a/ee/spec/features/search/user_searches_for_epics_spec.rb +++ b/ee/spec/features/search/user_searches_for_epics_spec.rb @@ -2,11 +2,9 @@ require 'spec_helper' -RSpec.describe 'User searches for epics', :elastic, :sidekiq_inline, :js, :disable_rate_limiter, feature_category: :global_search do +RSpec.describe 'User searches for epics', :js, :disable_rate_limiter, feature_category: :global_search do let_it_be(:user) { create(:user) } let_it_be(:group) { create(:group) } - let_it_be(:epic1) { create(:epic, title: 'Foo', group: group, updated_at: 6.days.ago) } - let_it_be(:epic2) { create(:epic, :closed, :confidential, title: 'Bar', group: group) } before do stub_licensed_features(epics: true) @@ -18,9 +16,17 @@ visit(search_path(group_id: group.id)) end - include_examples 'top right search form' - include_examples 'search timeouts', 'epics' do - let(:additional_params) { { group_id: group.id } } + [:work_item, :epic].each do |document_type| + context "when we have document_type as #{document_type}", :elastic do + before do + stub_feature_flags(search_epics_uses_work_items_index: (document_type == :work_item)) + end + + include_examples 'top right search form' + include_examples 'search timeouts', 'epics' do + let(:additional_params) { { group_id: group.id } } + end + end end shared_examples 'searches for epics' do @@ -74,18 +80,41 @@ end end - context 'when advanced_search is enabled' do + context 'when advanced_search is enabled', :elastic do + let_it_be(:epic1) do + create(:work_item, :group_level, :epic_with_legacy_epic, title: 'Foo', namespace: group, + updated_at: 6.days.ago) + end + + let_it_be(:epic2) do + create(:work_item, :group_level, :epic_with_legacy_epic, :closed, :confidential, title: 'Bar', + namespace: group) + end + before do stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true) - + stub_feature_flags(search_epics_uses_work_items_index: true) Elastic::ProcessBookkeepingService.track!(*[epic1, epic2]) ensure_elasticsearch_index! end include_examples 'searches for epics' + context 'when ff is disabled' do + let_it_be(:epic1) { create(:epic, title: 'Foo', group: group, updated_at: 6.days.ago) } + let_it_be(:epic2) { create(:epic, :closed, :confidential, title: 'Bar', group: group) } + + before do + stub_feature_flags(search_epics_uses_work_items_index: false) + end + + include_examples 'searches for epics' + end end context 'when advanced_search is disabled' do + let_it_be(:epic1) { create(:epic, title: 'Foo', group: group, updated_at: 6.days.ago) } + let_it_be(:epic2) { create(:epic, :closed, :confidential, title: 'Bar', group: group) } + before do stub_ee_application_setting(elasticsearch_search: false, elasticsearch_indexing: false) end diff --git a/ee/spec/lib/gitlab/elastic/group_search_results_spec.rb b/ee/spec/lib/gitlab/elastic/group_search_results_spec.rb index 167635b47344d..1a051173e3c3d 100644 --- a/ee/spec/lib/gitlab/elastic/group_search_results_spec.rb +++ b/ee/spec/lib/gitlab/elastic/group_search_results_spec.rb @@ -236,6 +236,61 @@ end end + context 'group level work_items search', :sidekiq_inline do + let(:query) { 'foo' } + let(:scope) { 'epics' } + + let_it_be(:public_parent_group) { create(:group, :public) } + let_it_be(:group) { create(:group, :private, parent: public_parent_group) } + let_it_be(:child_group) { create(:group, :private, parent: group) } + let_it_be(:child_of_child_group) { create(:group, :private, parent: child_group) } + let_it_be(:another_group) { create(:group, :private, parent: public_parent_group) } + let!(:parent_group_epic) { create(:work_item, :group_level, :epic_with_legacy_epic, namespace: public_parent_group, title: query) } + let!(:group_epic) { create(:work_item, :group_level, :epic_with_legacy_epic, namespace: group, title: query) } + let!(:child_group_epic) { create(:work_item, :group_level, :epic_with_legacy_epic, namespace: child_group, title: query) } + let!(:confidential_child_group_epic) { create(:work_item, :group_level, :epic_with_legacy_epic, :confidential, namespace: child_group, title: query) } + let!(:confidential_child_of_child_epic) { create(:work_item, :group_level, :epic_with_legacy_epic, :confidential, namespace: child_of_child_group, title: query) } + let!(:another_group_epic) { create(:work_item, :group_level, :epic_with_legacy_epic, namespace: another_group, title: query) } + + before do + ensure_elasticsearch_index! + end + + it 'returns no epics' do + expect(results.objects('epics')).to be_empty + end + + context 'when the user is a developer on the group' do + before_all do + group.add_developer(user) + end + + it 'returns matching epics belonging to the group or its descendants, including confidential epics' do + epics = results.objects('epics') + + expect(epics).to include(group_epic) + expect(epics).to include(child_group_epic) + expect(epics).to include(confidential_child_group_epic) + + expect(epics).not_to include(parent_group_epic) + expect(epics).not_to include(another_group_epic) + end + + context 'when searching from the child group' do + it 'returns matching epics belonging to the child group, including confidential epics' do + epics = described_class.new(user, query, [], group: child_group, filters: filters).objects('epics') + + expect(epics).to include(child_group_epic) + expect(epics).to include(confidential_child_group_epic) + + expect(epics).not_to include(group_epic) + expect(epics).not_to include(parent_group_epic) + expect(epics).not_to include(another_group_epic) + end + end + end + end + context 'epics search', :sidekiq_inline do let(:query) { 'foo' } let(:scope) { 'epics' } @@ -253,6 +308,7 @@ let!(:another_group_epic) { create(:epic, group: another_group, title: query) } before do + stub_feature_flags(search_epics_uses_work_items_index: false) ensure_elasticsearch_index! end diff --git a/ee/spec/lib/search/elastic/filters_spec.rb b/ee/spec/lib/search/elastic/filters_spec.rb index 76f44a8f5bb79..eb686deb7034b 100644 --- a/ee/spec/lib/search/elastic/filters_spec.rb +++ b/ee/spec/lib/search/elastic/filters_spec.rb @@ -767,6 +767,182 @@ end end + describe '.by_group_level_authorization' do + subject(:by_group_level_authorization) do + described_class.by_group_level_authorization(query_hash: query_hash, options: options) + end + + context 'when user.can_read_all_resources? is true' do + let(:base_options) { { current_user: user, search_level: 'global' } } + let(:options) { base_options } + + before do + allow(user).to receive(:can_read_all_resources?).and_return(true) + end + + it_behaves_like 'does not modify the query_hash' + end + + context 'when user is having permission for the group' do + let_it_be(:group) { create(:group, :private) } + let(:base_options) { { current_user: user, search_level: 'group', group_id: group.id, group_ids: [group.id] } } + let(:options) { base_options } + + before_all do + group.add_developer(user) + end + + it 'shows private filter' do + expected_filter = [ + { bool: { _name: "filters:level:group", minimum_should_match: 1, + should: [{ prefix: { + traversal_ids: { + _name: "filters:level:group:ancestry_filter:descendants", + value: group.elastic_namespace_ancestry + } + } }] } }, + { bool: { + minimum_should_match: 1, + should: [ + { bool: { filter: [ + { term: { namespace_visibility_level: { + _name: 'filters:namespace_visibility_level:public', value: ::Gitlab::VisibilityLevel::PUBLIC + } } } + ] } }, + { bool: { filter: [{ term: { namespace_visibility_level: { + _name: 'filters:namespace_visibility_level:internal', value: ::Gitlab::VisibilityLevel::INTERNAL + } } }] } }, + { bool: { filter: [ + { term: { + namespace_visibility_level: { _name: 'filters:namespace_visibility_level:private', + value: ::Gitlab::VisibilityLevel::PRIVATE } + } }, + { terms: { namespace_id: [group.id] } } + ] } } + ] + } } + ] + + expect(by_group_level_authorization.dig(:query, :bool, :filter)).to match(expected_filter) + expect(by_group_level_authorization.dig(:query, :bool, :must)).to be_empty + expect(by_group_level_authorization.dig(:query, :bool, :must_not)).to be_empty + expect(by_group_level_authorization.dig(:query, :bool, :should)).to be_empty + end + end + + context 'when user is nil' do + let(:options) { base_options } + let(:base_options) { { current_user: nil, search_level: 'global' } } + + it 'shows only the public filter' do + expected_filter = [ + bool: { + minimum_should_match: 1, + should: [ + { bool: { filter: [{ term: { namespace_visibility_level: { + _name: 'filters:namespace_visibility_level:public', value: ::Gitlab::VisibilityLevel::PUBLIC + } } }] } } + ] + } + ] + + expect(by_group_level_authorization.dig(:query, :bool, :filter)).to match(expected_filter) + expect(by_group_level_authorization.dig(:query, :bool, :must)).to be_empty + expect(by_group_level_authorization.dig(:query, :bool, :must_not)).to be_empty + expect(by_group_level_authorization.dig(:query, :bool, :should)).to be_empty + end + end + + context 'when user is not having permissions to read confidential epics' do + let(:options) { base_options } + let(:base_options) { { current_user: user, search_level: 'global' } } + + it 'shows only the public filter' do + expected_filter = [ + bool: { + minimum_should_match: 1, + should: [ + { bool: { filter: [{ term: { namespace_visibility_level: { + _name: 'filters:namespace_visibility_level:public', value: ::Gitlab::VisibilityLevel::PUBLIC + } } }] } }, + { bool: { filter: [{ term: { namespace_visibility_level: { + _name: 'filters:namespace_visibility_level:internal', value: ::Gitlab::VisibilityLevel::INTERNAL + } } }] } } + ] + } + ] + + expect(by_group_level_authorization.dig(:query, :bool, :filter)).to match(expected_filter) + expect(by_group_level_authorization.dig(:query, :bool, :must)).to be_empty + expect(by_group_level_authorization.dig(:query, :bool, :must_not)).to be_empty + expect(by_group_level_authorization.dig(:query, :bool, :should)).to be_empty + end + end + end + + describe '.by_group_level_confidentiality' do + subject(:by_group_level_confidentiality) do + described_class.by_group_level_confidentiality(query_hash: query_hash, options: options) + end + + context 'when user.can_read_all_resources? is true' do + let(:base_options) { { current_user: user, search_level: 'global' } } + let(:options) { base_options } + + before do + allow(user).to receive(:can_read_all_resources?).and_return(true) + end + + it_behaves_like 'does not modify the query_hash' + end + + context 'when user is having permission for the group' do + let_it_be(:group) { create(:group, :private) } + let(:base_options) { { current_user: user, search_level: 'global' } } + let(:options) { base_options } + + before_all do + group.add_developer(user) + end + + it 'shows the expected filter' do + expected_filter = [ + bool: { should: [ + { + bool: { + must: [ + { term: { confidential: { value: true, _name: "filters:confidential:groups" } } }, + { terms: { namespace_id: [group.id], + _name: "filters:confidential:groups:can_read_confidential_work_items" } } + ] + } + }, + { term: { confidential: { value: false, _name: "filters:non_confidential:groups" } } } + ] } + ] + + expect(by_group_level_confidentiality.dig(:query, :bool, :filter)).to match(expected_filter) + expect(by_group_level_confidentiality.dig(:query, :bool, :must)).to be_empty + expect(by_group_level_confidentiality.dig(:query, :bool, :must_not)).to be_empty + expect(by_group_level_confidentiality.dig(:query, :bool, :should)).to be_empty + end + end + + context 'when user is nil' do + let(:options) { base_options } + let(:base_options) { { current_user: nil, search_level: 'global' } } + + it_behaves_like 'does not modify the query_hash' + end + + context 'when user is not having permissions to read confidential epics' do + let(:options) { base_options } + let(:base_options) { { current_user: user, search_level: 'global' } } + + it_behaves_like 'does not modify the query_hash' + end + end + describe '.by_confidentiality' do let_it_be(:authorized_project) { create(:project, developers: [user]) } let_it_be(:private_project) { create(:project, :private) } diff --git a/ee/spec/services/search/global_service_spec.rb b/ee/spec/services/search/global_service_spec.rb index 9bbdb8b194675..b5134159e5f40 100644 --- a/ee/spec/services/search/global_service_spec.rb +++ b/ee/spec/services/search/global_service_spec.rb @@ -200,8 +200,12 @@ context 'epic' do let(:scope) { 'epics' } - let!(:epic) { create :epic, group: group } - let(:search) { epic.title } + let(:search) { 'chosen epic title' } + let!(:epic) { create(:work_item, :group_level, :epic_with_legacy_epic, namespace: group, title: 'chosen epic title') } + + before do + ensure_elasticsearch_index! + end where(:group_level, :membership, :admin_mode, :expected_count) do permission_table_for_epics_access @@ -218,6 +222,30 @@ end end end + context "when we have ff disabled" do + let!(:epic) { create(:epic, group: group, title: 'chosen epic title') } + + before do + stub_feature_flags(search_epics_uses_work_items_index: false) + ensure_elasticsearch_index! + end + + where(:group_level, :membership, :admin_mode, :expected_count) do + permission_table_for_epics_access + end + + with_them do + it 'respects visibility' do + enable_admin_mode!(user_in_group) if admin_mode + + group.update!(visibility_level: Gitlab::VisibilityLevel.level_value(group_level.to_s)) + ensure_elasticsearch_index! + expect_search_results(user_in_group, scope, expected_count: expected_count) do |user| + described_class.new(user, search: search).execute + end + end + end + end end context 'wiki' do diff --git a/ee/spec/services/search/group_service_spec.rb b/ee/spec/services/search/group_service_spec.rb index 2041ebfcf1215..64f7914de249f 100644 --- a/ee/spec/services/search/group_service_spec.rb +++ b/ee/spec/services/search/group_service_spec.rb @@ -605,21 +605,65 @@ end end + context 'for group level work_items' do + let(:scope) { 'epics' } + let_it_be(:member) { create(:group_member, :owner, group: group, user: user) } + + let_it_be(:old_result) do + create(:work_item, :group_level, :epic_with_legacy_epic, namespace: group, title: 'sorted old', + created_at: 1.month.ago) + end + + let_it_be(:new_result) do + create(:work_item, :group_level, :epic_with_legacy_epic, namespace: group, title: 'sorted recent', + created_at: 1.day.ago) + end + + let_it_be(:very_old_result) do + create(:work_item, :group_level, :epic_with_legacy_epic, namespace: group, title: 'sorted very old', + created_at: 1.year.ago) + end + + let_it_be(:old_updated) do + create(:work_item, :group_level, :epic_with_legacy_epic, namespace: group, title: 'updated old', + updated_at: 1.month.ago) + end + + let_it_be(:new_updated) do + create(:work_item, :group_level, :epic_with_legacy_epic, namespace: group, title: 'updated recent', + updated_at: 1.day.ago) + end + + let_it_be(:very_old_updated) do + create(:work_item, :group_level, :epic_with_legacy_epic, namespace: group, title: 'updated very old', + updated_at: 1.year.ago) + end + + let(:results_created) { described_class.new(user, group, search: 'sorted', sort: sort).execute } + let(:results_updated) { described_class.new(user, group, search: 'updated', sort: sort).execute } + + before do + stub_licensed_features(epics: true) + Elastic::ProcessInitialBookkeepingService.track!(old_result, new_result, very_old_result, + old_updated, new_updated, very_old_updated + ) + ensure_elasticsearch_index! + end + + include_examples 'search results sorted' + end + context 'for epics' do let(:scope) { 'epics' } let_it_be(:member) { create(:group_member, :owner, group: group, user: user) } let_it_be(:old_result) { create(:epic, group: group, title: 'sorted old', created_at: 1.month.ago) } let_it_be(:new_result) { create(:epic, group: group, title: 'sorted recent', created_at: 1.day.ago) } - let_it_be(:very_old_result) do - create(:epic, group: group, title: 'sorted very old', created_at: 1.year.ago) - end + let_it_be(:very_old_result) { create(:epic, group: group, title: 'sorted very old', created_at: 1.year.ago) } let_it_be(:old_updated) { create(:epic, group: group, title: 'updated old', updated_at: 1.month.ago) } let_it_be(:new_updated) { create(:epic, group: group, title: 'updated recent', updated_at: 1.day.ago) } - let_it_be(:very_old_updated) do - create(:epic, group: group, title: 'updated very old', updated_at: 1.year.ago) - end + let_it_be(:very_old_updated) { create(:epic, group: group, title: 'updated very old', updated_at: 1.year.ago) } let(:results_created) { described_class.new(user, group, search: 'sorted', sort: sort).execute } let(:results_updated) { described_class.new(user, group, search: 'updated', sort: sort).execute } @@ -629,6 +673,7 @@ Elastic::ProcessInitialBookkeepingService.track!(old_result, new_result, very_old_result, old_updated, new_updated, very_old_updated ) + stub_feature_flags(search_epics_uses_work_items_index: false) ensure_elasticsearch_index! end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 0f8b662aeec97..fd8e810ccbf5e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -883,6 +883,9 @@ msgstr "" msgid "%{group_name}&%{epic_iid} · created %{epic_created} by %{author}" msgstr "" +msgid "%{group_name}&%{work_item_iid} · created %{work_item_created} by %{author}" +msgstr "" + msgid "%{groupsLength} group" msgid_plural "%{groupsLength} groups" msgstr[0] "" -- GitLab