diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index d5a88a776713eaf1dd69fb2b1ae8ac35f0a503f2..2d280e9c3c7324eac9f00b6ff39f401cf3a5dc82 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -6,7 +6,6 @@ class GlobalService include Gitlab::Utils::StrongMemoize DEFAULT_SCOPE = 'projects' - ALLOWED_SCOPES = %w[projects issues merge_requests milestones users].freeze attr_accessor :current_user, :params @@ -31,7 +30,16 @@ def projects end def allowed_scopes - ALLOWED_SCOPES + strong_memoize(:allowed_scopes) do + Search::Scopes.available_for_context( + context: :global, + search_type: allowed_scopes_search_type + ) + end + end + + def search_type + 'basic' end def scope @@ -39,6 +47,14 @@ def scope allowed_scopes.include?(params[:scope]) ? params[:scope] : DEFAULT_SCOPE end end + + private + + # Determines search type for allowed_scopes calculation + # Cannot use full search_type method as it may depend on scope (circular dependency) + def allowed_scopes_search_type + :basic + end end end diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index c2e789790afec6fa49485bab8b3258d9f061a81f..3b7e600364687fed84a05f4d9da136fe27212a8d 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -5,8 +5,6 @@ class ProjectService include Search::Filter include Gitlab::Utils::StrongMemoize - ALLOWED_SCOPES = %w[blobs issues merge_requests wiki_blobs commits notes milestones users].freeze - attr_accessor :project, :current_user, :params def initialize(user, project, params) @@ -27,7 +25,16 @@ def execute end def allowed_scopes - ALLOWED_SCOPES + strong_memoize(:allowed_scopes) do + Search::Scopes.available_for_context( + context: :project, + search_type: allowed_scopes_search_type + ) + end + end + + def search_type + 'basic' end def scope @@ -41,6 +48,14 @@ def scope end end end + + private + + # Determines search type for allowed_scopes calculation + # Cannot use full search_type method as it may depend on scope (circular dependency) + def allowed_scopes_search_type + :basic + end end end diff --git a/ee/app/services/ee/search/global_service.rb b/ee/app/services/ee/search/global_service.rb index 4ac4a4e100f7b701c309a8816c666e44be2f08d8..c616fefa7a590dad0768e1b061721c241a0702c4 100644 --- a/ee/app/services/ee/search/global_service.rb +++ b/ee/app/services/ee/search/global_service.rb @@ -72,17 +72,35 @@ def zoekt_project_id; end def zoekt_group_id; end - override :allowed_scopes - def allowed_scopes - scopes = super - return scopes if params[:search_type] == 'basic' + private - scopes += %w[blobs commits epics notes wiki_blobs] if use_elasticsearch? - scopes += %w[blobs] if use_zoekt? + override :allowed_scopes_search_type + def allowed_scopes_search_type + # For allowed_scopes calculation, we check elasticsearch/zoekt availability + # but don't check scope (which would create circular dependency) + return params[:search_type].to_sym if params[:search_type] + return :zoekt if zoekt_enabled_for_scopes? + return :advanced if elasticsearch_enabled_for_scopes? - scopes.uniq + :basic + end + + def elasticsearch_enabled_for_scopes? + # Use search_using_elasticsearch? with nil scope to check global search availability + # This properly handles elasticsearch_search, elasticsearch_indexing, + # elasticsearch_limit_indexing, and global_search_limited_indexing_enabled settings + ::Gitlab::CurrentSettings.search_using_elasticsearch?(scope: nil) + rescue StandardError + false + end + + def zoekt_enabled_for_scopes? + # Check if zoekt is available for the user + # This uses the same logic as Search::Zoekt but without depending on scope + zoekt_searchable_scope? && ::Search::Zoekt.enabled_for_user?(current_user) + rescue StandardError + false end - strong_memoize_attr :allowed_scopes end end end diff --git a/ee/app/services/ee/search/group_service.rb b/ee/app/services/ee/search/group_service.rb index f0dc6acdf6beaa740895d1e13b8eda14c05927f2..0a5a65494fbdca4205154ed94df780be1151e62e 100644 --- a/ee/app/services/ee/search/group_service.rb +++ b/ee/app/services/ee/search/group_service.rb @@ -65,6 +65,37 @@ def allowed_scopes end.uniq end strong_memoize_attr :allowed_scopes + + private + + override :allowed_scopes_search_type + def allowed_scopes_search_type + # For allowed_scopes calculation, we check elasticsearch/zoekt availability + # but don't check scope (which would create circular dependency) + return params[:search_type].to_sym if params[:search_type] + return :zoekt if zoekt_enabled_for_scopes? + return :advanced if elasticsearch_enabled_for_scopes_in_group? + + :basic + end + + def elasticsearch_enabled_for_scopes_in_group? + # Use search_using_elasticsearch? with group scope to check group search availability + # This properly handles elasticsearch_search, elasticsearch_indexing, + # elasticsearch_limit_indexing, and group-level settings + ::Gitlab::CurrentSettings.search_using_elasticsearch?(scope: group) + rescue StandardError + false + end + + def zoekt_enabled_for_scopes? + # Check if zoekt is available for the group + ::Search::Zoekt.enabled_for_user?(current_user) && + ::Search::Zoekt.search?(group) && + zoekt_node_available_for_search? + rescue StandardError + false + end end end end diff --git a/ee/app/services/ee/search/project_service.rb b/ee/app/services/ee/search/project_service.rb index 5952056dafd50f4cf1ea7127791b605632ee91a8..94f489078b9db1cf619c8f62f2a02450389a0c52 100644 --- a/ee/app/services/ee/search/project_service.rb +++ b/ee/app/services/ee/search/project_service.rb @@ -77,6 +77,18 @@ def zoekt_projects def zoekt_nodes @zoekt_nodes ||= ::Search::Zoekt::Node.searchable_for_project(zoekt_searchable_scope) end + + private + + override :allowed_scopes_search_type + def allowed_scopes_search_type + # For allowed_scopes calculation, we check elasticsearch availability + # but don't check scope or repository_ref (which would create circular dependency) + return params[:search_type].to_sym if params[:search_type] + return :advanced if use_elasticsearch? + + :basic + end end end end diff --git a/ee/lib/ee/search/navigation.rb b/ee/lib/ee/search/navigation.rb index 986e361f62a3ae3f0d66ec09365335c1fb4fd7b7..3247a39f907de118be7ce3a0ace3296adc34a299 100644 --- a/ee/lib/ee/search/navigation.rb +++ b/ee/lib/ee/search/navigation.rb @@ -7,18 +7,13 @@ module Navigation override :tabs def tabs + # CE now automatically includes epics from Search::Scopes.scope_definitions + # We just need to handle the work_item_scope_frontend feature flag case (super || {}).tap do |nav| if ::Feature.enabled?(:work_item_scope_frontend, user) nav[:issues][:sub_items] ||= {} nav[:issues][:sub_items].merge!(get_epic_sub_item) - next end - - nav[:epics] ||= { - sort: 3, - label: _("Epics"), - condition: show_epics_search_tab? - } end end diff --git a/ee/lib/ee/search/scopes.rb b/ee/lib/ee/search/scopes.rb new file mode 100644 index 0000000000000000000000000000000000000000..b654d4f1cfcba3482a3a6bd1165548b88129cdb8 --- /dev/null +++ b/ee/lib/ee/search/scopes.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module EE + module Search + module Scopes + # EE scope definitions + # rubocop:disable Cop/StaticTranslationDefinition -- labels are defined in constant for registry + EE_SCOPE_DEFINITIONS = { + epics: { + label: _('Epics'), + sort: 3, + contexts: [:group, :global], + search_types: [:advanced] + } + }.freeze + # rubocop:enable Cop/StaticTranslationDefinition + + # EE-specific global search settings + EE_GLOBAL_SEARCH_SETTING_MAP = { + 'blobs' => :global_search_code_enabled?, + 'wiki_blobs' => :global_search_wiki_enabled?, + 'commits' => :global_search_commits_enabled?, + 'epics' => :global_search_epics_enabled? + }.freeze + + def self.prepended(base) + class << base + prepend ClassMethods + end + end + + module ClassMethods + extend ::Gitlab::Utils::Override + + override :scope_definitions + def scope_definitions + # Merge CE and EE definitions + super.merge(EE::Search::Scopes::EE_SCOPE_DEFINITIONS) + end + + override :current_search_type + def current_search_type + # Determine current search type based on what's enabled + # Priority: zoekt > advanced > basic + return :zoekt if zoekt_enabled? + return :advanced if elasticsearch_enabled? + + :basic + end + + private + + override :global_search_setting_map + def global_search_setting_map + # Merge CE and EE settings maps + super.merge(EE::Search::Scopes::EE_GLOBAL_SEARCH_SETTING_MAP) + end + + def elasticsearch_enabled? + Gitlab::CurrentSettings.elasticsearch_search? || + Gitlab::CurrentSettings.elasticsearch_indexing? + rescue StandardError + false + end + + def zoekt_enabled? + # rubocop:disable Gitlab/FeatureFlagWithoutActor -- ops-type flag checked globally + ::Feature.enabled?(:zoekt_cross_namespace_search, type: :ops) + # rubocop:enable Gitlab/FeatureFlagWithoutActor + rescue StandardError + false + end + end + end + end +end diff --git a/ee/spec/lib/ee/search/scopes_spec.rb b/ee/spec/lib/ee/search/scopes_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..17d7fbeda3c6096956d264e7c58522e86dbb19e9 --- /dev/null +++ b/ee/spec/lib/ee/search/scopes_spec.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Search::Scopes, feature_category: :global_search do + describe '.available_for_context' do + context 'for global context' do + it 'includes EE scopes with advanced search' do + allow(Gitlab::CurrentSettings).to receive(:elasticsearch_search?).and_return(true) + scopes = described_class.available_for_context(context: :global, search_type: :advanced) + + expect(scopes).to include('epics') + end + + it 'includes code scopes with advanced search' do + allow(Gitlab::CurrentSettings).to receive(:elasticsearch_search?).and_return(true) + scopes = described_class.available_for_context(context: :global, search_type: :advanced) + + expect(scopes).to include('blobs', 'commits', 'wiki_blobs', 'notes') + end + end + + context 'for group context' do + it 'includes epics with advanced search' do + allow(Gitlab::CurrentSettings).to receive(:elasticsearch_search?).and_return(true) + scopes = described_class.available_for_context(context: :group, search_type: :advanced) + + expect(scopes).to include('epics') + end + end + end + + describe '.valid_scope?' do + it 'returns true for EE scopes' do + expect(described_class.valid_scope?('epics')).to be true + end + + it 'returns true for CE scopes' do + expect(described_class.valid_scope?('projects')).to be true + expect(described_class.valid_scope?('issues')).to be true + end + + context 'with context parameter' do + it 'validates epics scope for group context' do + expect(described_class.valid_scope?('epics', context: :group)).to be true + expect(described_class.valid_scope?('epics', context: :global)).to be true + expect(described_class.valid_scope?('epics', context: :project)).to be false + end + end + end + + describe '.scope_enabled_for_global_search?' do + let(:settings) { instance_double(ApplicationSetting) } + + context 'with EE-specific scopes' do + it 'checks EE-specific settings for code search' do + allow(settings).to receive(:global_search_code_enabled?).and_return(false) + + expect(described_class.scope_enabled_for_global_search?('blobs', settings, :global)).to be false + end + + it 'checks EE-specific settings for epics' do + allow(settings).to receive(:global_search_epics_enabled?).and_return(false) + + expect(described_class.scope_enabled_for_global_search?('epics', settings, :global)).to be false + end + + it 'checks EE-specific settings for wiki' do + allow(settings).to receive(:global_search_wiki_enabled?).and_return(false) + + expect(described_class.scope_enabled_for_global_search?('wiki_blobs', settings, :global)).to be false + end + + it 'checks EE-specific settings for commits' do + allow(settings).to receive(:global_search_commits_enabled?).and_return(false) + + expect(described_class.scope_enabled_for_global_search?('commits', settings, :global)).to be false + end + end + end + + describe '.default_scope_options' do + context 'with advanced search enabled' do + before do + allow(described_class).to receive(:current_search_type).and_return(:advanced) + end + + it 'includes EE scopes and advanced search scopes' do + options = described_class.default_scope_options + values = options.pluck(:value) + + expect(values).to include('epics', 'blobs', 'commits') + end + + it 'maintains sort order with EE scopes' do + options = described_class.default_scope_options + values = options.pluck(:value) + + # Epics (sort: 3) should be between blobs (sort: 2) and issues (sort: 4) + expect(values.index('blobs')).to be < values.index('epics') + expect(values.index('epics')).to be < values.index('issues') + end + end + end + + describe '.validate_default_scope' do + let(:settings) { instance_double(ApplicationSetting) } + + context 'with EE scopes' do + it 'validates EE scopes correctly when enabled' do + allow(settings).to receive(:global_search_epics_enabled?).and_return(true) + errors = described_class.validate_default_scope('epics', settings) + + expect(errors).to be_empty + end + + it 'returns error when EE scope is disabled' do + allow(settings).to receive(:global_search_epics_enabled?).and_return(false) + errors = described_class.validate_default_scope('epics', settings) + + expect(errors).to include(match(/currently disabled/)) + end + + it 'validates code scope with settings' do + allow(settings).to receive(:global_search_code_enabled?).and_return(false) + errors = described_class.validate_default_scope('blobs', settings) + + expect(errors).to include(match(/currently disabled/)) + end + end + end + + describe '.current_search_type' do + it 'returns :basic by default' do + allow(Gitlab::CurrentSettings).to receive_messages(elasticsearch_search?: false, elasticsearch_indexing?: false) + stub_feature_flags(zoekt_cross_namespace_search: false) + + expect(described_class.current_search_type).to eq(:basic) + end + + context 'with elasticsearch enabled' do + it 'returns :advanced' do + stub_feature_flags(zoekt_cross_namespace_search: false) + stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: false) + + expect(described_class.current_search_type).to eq(:advanced) + end + end + + context 'with zoekt enabled' do + before do + allow(Gitlab::CurrentSettings).to receive_messages(elasticsearch_search?: false, elasticsearch_indexing?: false) + stub_feature_flags(zoekt_cross_namespace_search: true) + end + + it 'returns :zoekt' do + expect(described_class.current_search_type).to eq(:zoekt) + end + end + + context 'with both elasticsearch and zoekt enabled' do + before do + allow(Gitlab::CurrentSettings).to receive_messages(elasticsearch_search?: true, elasticsearch_indexing?: false) + stub_feature_flags(zoekt_cross_namespace_search: true) + end + + it 'prioritizes zoekt over elasticsearch' do + expect(described_class.current_search_type).to eq(:zoekt) + end + end + end +end diff --git a/lib/gitlab/metrics/global_search_slis.rb b/lib/gitlab/metrics/global_search_slis.rb index 5c21d4f32ce0179766c666a078f5f304067961b0..2e56e23f855855e3fb995164f323e5931dfbe700 100644 --- a/lib/gitlab/metrics/global_search_slis.rb +++ b/lib/gitlab/metrics/global_search_slis.rb @@ -64,7 +64,7 @@ def search_levels end def search_scopes - Gitlab::Search::AbuseDetection::ALLOWED_SCOPES + ::Search::Scopes.all_scope_names end def endpoint_ids diff --git a/lib/gitlab/search/abuse_detection.rb b/lib/gitlab/search/abuse_detection.rb index 8edc6d549b4cff03c3d7cae1ebdac5c3a6a8f244..226ce0f3bb27bdcfad276c5ec817e3fa6f493301 100644 --- a/lib/gitlab/search/abuse_detection.rb +++ b/lib/gitlab/search/abuse_detection.rb @@ -10,21 +10,6 @@ class AbuseDetection ABUSIVE_TERM_SIZE = 100 ALLOWED_CHARS_REGEX = %r{\A[[:alnum:]_\-\+\/\.!]+\z} - ALLOWED_SCOPES = %w[ - blobs - code - commits - epics - issues - merge_requests - milestones - notes - projects - snippet_titles - users - wiki_blobs - ].freeze - READABLE_PARAMS = %i[ group_id project_id @@ -42,7 +27,9 @@ class AbuseDetection validates :project_id, :group_id, numericality: { only_integer: true, message: "abusive ID detected" }, allow_blank: true - validates :scope, inclusion: { in: ALLOWED_SCOPES, message: 'abusive scope detected' }, allow_blank: true + validates :scope, inclusion: { in: -> { + ::Search::Scopes.all_scope_names + }, message: 'abusive scope detected' }, allow_blank: true validates :repository_ref, :project_ref, format: { with: ALLOWED_CHARS_REGEX, message: "abusive characters detected" }, allow_blank: true diff --git a/lib/search/navigation.rb b/lib/search/navigation.rb index 093605a6faa5b642af1a8b135bfe2bceebff3769..e9b071455adc606f009886e76351451b3de3849d 100644 --- a/lib/search/navigation.rb +++ b/lib/search/navigation.rb @@ -19,61 +19,20 @@ def tab_enabled_for_project?(tab) end def tabs - nav = { - projects: { - sort: 1, - label: _("Projects"), - data: { testid: 'projects-tab' }, - condition: project.nil? - }, - blobs: { - sort: 2, - label: _("Code"), - data: { testid: 'code-tab' }, - condition: show_code_search_tab? - }, - # sort: 3 is reserved for EE items - issues: { - sort: 4, - label: _("Issues"), - condition: show_issues_search_tab? - }, - merge_requests: { - sort: 5, - label: _("Merge requests"), - condition: show_merge_requests_search_tab? - }, - wiki_blobs: { - sort: 6, - label: _("Wiki"), - condition: show_wiki_search_tab? - }, - commits: { - sort: 7, - label: _("Commits"), - condition: show_commits_search_tab? - }, - notes: { - sort: 8, - label: _("Comments"), - condition: show_comments_search_tab? - }, - milestones: { - sort: 9, label: _("Milestones"), - condition: show_milestones_search_tab? - }, - users: { - sort: 10, - label: _("Users"), - condition: show_user_search_tab? - }, - snippet_titles: { - sort: 11, - label: _("Snippets"), - search: { snippets: true, group_id: nil, project_id: nil }, - condition: show_snippets_search_tab? + # Build tabs from Search::Scopes registry + # This automatically includes EE scopes when running in EE + nav = {} + Search::Scopes.scope_definitions.each do |scope_key, definition| + nav[scope_key] = { + sort: definition[:sort], + label: definition[:label], + data: scope_key == :blobs ? { testid: 'code-tab' } : { testid: "#{scope_key.to_s.tr('_', '-')}-tab" }, + condition: scope_visibility_condition(scope_key) } - } + + # Add special search params for snippet_titles + nav[scope_key][:search] = { snippets: true, group_id: nil, project_id: nil } if scope_key == :snippet_titles + end return nav unless ::Feature.enabled?(:work_item_scope_frontend, user) @@ -89,6 +48,38 @@ def tabs private + # Map scope key to its visibility condition method + # This method is called for each scope defined in Search::Scopes::SCOPE_DEFINITIONS + def scope_visibility_condition(scope_key) + case scope_key + when :projects + project.nil? + when :blobs + show_code_search_tab? + when :issues + show_issues_search_tab? + when :merge_requests + show_merge_requests_search_tab? + when :wiki_blobs + show_wiki_search_tab? + when :commits + show_commits_search_tab? + when :notes + show_comments_search_tab? + when :milestones + show_milestones_search_tab? + when :users + show_user_search_tab? + when :snippet_titles + show_snippets_search_tab? + when :epics + # EE scope - will be false in CE, overridden in EE + respond_to?(:show_epics_search_tab?, true) ? show_epics_search_tab? : false + else + false + end + end + def get_sub_items ::WorkItems::Type::TYPE_NAMES.each_with_object({}) do |(key, value, index), hash| next if key.to_s == 'epic' diff --git a/lib/search/scopes.rb b/lib/search/scopes.rb new file mode 100644 index 0000000000000000000000000000000000000000..d3a1f4a1a417b9f013d2fc4e389db9ebe76fe183 --- /dev/null +++ b/lib/search/scopes.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +module Search + # Central registry for search scopes across all search contexts + # Provides a single source of truth for scope definitions, availability, + # and validation logic + class Scopes + # Scope definitions with metadata (CE scopes only) + # Format: { scope_key => { label:, sort:, contexts:, search_types: } } + # EE scopes are defined in ee/lib/ee/search/scopes.rb + # rubocop:disable Cop/StaticTranslationDefinition -- labels are defined in constant for registry + SCOPE_DEFINITIONS = { + projects: { + label: _('Projects'), + sort: 1, + contexts: [:global, :group], + search_types: [:basic, :advanced, :zoekt] + }, + blobs: { + label: _('Code'), + sort: 2, + contexts: [:project, :group, :global], + search_types: [:basic, :advanced, :zoekt], + requires_advanced_search_for: [:global, :group], + requires_zoekt_for: [:global, :group] + }, + # sort: 3 is reserved for EE scopes (epics) + issues: { + label: _('Issues'), + sort: 4, + contexts: [:project, :group, :global], + search_types: [:basic, :advanced, :zoekt] + }, + merge_requests: { + label: _('Merge requests'), + sort: 5, + contexts: [:project, :group, :global], + search_types: [:basic, :advanced, :zoekt] + }, + wiki_blobs: { + label: _('Wiki'), + sort: 6, + contexts: [:project, :group, :global], + search_types: [:basic, :advanced], + requires_advanced_search_for: [:global, :group] + }, + commits: { + label: _('Commits'), + sort: 7, + contexts: [:project, :group, :global], + search_types: [:basic, :advanced], + requires_advanced_search_for: [:global, :group] + }, + notes: { + label: _('Comments'), + sort: 8, + contexts: [:project, :group, :global], + search_types: [:basic, :advanced], + requires_advanced_search_for: [:global, :group] + }, + milestones: { + label: _('Milestones'), + sort: 9, + contexts: [:project, :group, :global], + search_types: [:basic, :advanced, :zoekt] + }, + users: { + label: _('Users'), + sort: 10, + contexts: [:project, :group, :global], + search_types: [:basic, :advanced, :zoekt] + }, + snippet_titles: { + label: _('Snippets'), + sort: 11, + contexts: [:global, :group], + search_types: [:basic, :advanced] + } + }.freeze + # rubocop:enable Cop/StaticTranslationDefinition + + # Map of scopes to their required application setting for global search (CE scopes) + # EE scopes are added in ee/lib/ee/search/scopes.rb + GLOBAL_SEARCH_SETTING_MAP = { + 'issues' => :global_search_issues_enabled?, + 'merge_requests' => :global_search_merge_requests_enabled?, + 'snippet_titles' => :global_search_snippet_titles_enabled?, + 'users' => :global_search_users_enabled? + }.freeze + + class << self + # Get all scope names + def all_scope_names + scope_definitions.keys.map(&:to_s) + end + + # Get scopes available for a specific context (global, group, project) + # @param context [Symbol] :global, :group, or :project + # @param search_type [Symbol] :basic, :advanced, or :zoekt + # @return [Array] Array of scope names available for the context + def available_for_context(context:, search_type: :basic) + scope_definitions.select do |_key, definition| + definition[:contexts].include?(context) && + search_type_matches?(definition[:search_types], search_type, context, definition) + end.keys.map(&:to_s) + end + + # Check if a scope is valid for the current instance + # @param scope [String] Scope name to check + # @param context [Symbol] :global, :group, or :project (optional) + # @return [Boolean] True if scope is valid + def valid_scope?(scope, context: nil) + return false if scope.blank? + + scope_sym = scope.to_sym + definition = scope_definitions[scope_sym] + return false unless definition + + # Check context if provided + return false if context && definition[:contexts].exclude?(context) + + true + end + + # Get scopes that are enabled based on global search settings + # @param context [Symbol] :global, :group, or :project + # @param settings [ApplicationSetting] Application settings object + # @return [Array] Array of enabled scope names + def enabled_scopes(context:, settings: nil) + settings ||= ::Gitlab::CurrentSettings.current_application_settings + + available_for_context( + context: context, + search_type: current_search_type + ).select do |scope| + scope_enabled_for_global_search?(scope, settings, context) + end + end + + # Check if a scope is enabled for global search based on settings + # @param scope [String] Scope name + # @param settings [ApplicationSetting] Application settings object + # @param context [Symbol] Search context + # @return [Boolean] True if scope is enabled + def scope_enabled_for_global_search?(scope, settings, context) + # Project searches don't check global search settings + return true if context == :project + + setting_method = global_search_setting_map[scope.to_s] + return true unless setting_method + + settings.public_send(setting_method) # rubocop:disable GitlabSecurity/PublicSend -- setting_method is validated from GLOBAL_SEARCH_SETTING_MAP + end + + # Get the label for a scope + # @param scope [String] Scope name + # @return [String, nil] Human-readable label + def label_for(scope) + scope_definitions[scope.to_sym]&.dig(:label) + end + + # Get scopes suitable for default scope dropdown in admin UI + # Returns scopes that: + # - Are available on this instance (CE vs EE) + # - Can be used in multiple contexts (not snippet_titles which is special) + # - Are commonly useful as defaults + # @return [Array] Array of { value:, text: } for dropdown + def default_scope_options + options = available_for_context( + context: :global, + search_type: current_search_type + ).map do |scope| + { + value: scope, + text: label_for(scope) || scope.to_s.titleize + } + end + + # Sort by the defined sort order + options.sort_by do |opt| + scope_definitions[opt[:value].to_sym]&.dig(:sort) || 999 + end + end + + # Validate that a default scope is compatible with current settings + # @param scope [String] Scope to validate + # @param settings [ApplicationSetting] Application settings + # @return [Array] Array of error messages (empty if valid) + def validate_default_scope(scope, settings) + errors = [] + + return errors if scope.blank? + + unless valid_scope?(scope, context: :global) + errors << "#{scope} is not a valid search scope for this instance" + return errors + end + + # Check if scope is disabled by global search settings + setting_method = global_search_setting_map[scope] + # rubocop:disable GitlabSecurity/PublicSend -- setting_method is validated from GLOBAL_SEARCH_SETTING_MAP + if setting_method && !settings.public_send(setting_method) + # rubocop:enable GitlabSecurity/PublicSend + setting_name = setting_method.to_s.delete('?').humanize + errors << "#{label_for(scope)} scope is currently disabled. " \ + "Enable '#{setting_name}' before setting it as the default." + end + + errors + end + + # Returns the scope definitions (can be overridden in EE) + def scope_definitions + SCOPE_DEFINITIONS + end + + # Returns the current search type (can be overridden in EE) + def current_search_type + # CE only has basic search + :basic + end + + private + + # Returns the global search setting map (can be overridden in EE) + def global_search_setting_map + GLOBAL_SEARCH_SETTING_MAP + end + + def search_type_matches?(allowed_types, search_type, context, definition) + return false unless allowed_types.include?(search_type) + + # Check if this scope requires advanced search or zoekt for the given context + if search_type == :basic + requires_advanced = definition[:requires_advanced_search_for] + requires_zoekt = definition[:requires_zoekt_for] + + return false if requires_advanced&.include?(context) + return false if requires_zoekt&.include?(context) + end + + true + end + end + end +end + +Search::Scopes.prepend_mod diff --git a/spec/lib/gitlab/search/abuse_detection_spec.rb b/spec/lib/gitlab/search/abuse_detection_spec.rb index 5ec6693eceb5b3d3b7a34d18664a0c364e56b085..2f0f5e94dbc45628e9f55be97196c48ffa2e83ba 100644 --- a/spec/lib/gitlab/search/abuse_detection_spec.rb +++ b/spec/lib/gitlab/search/abuse_detection_spec.rb @@ -9,7 +9,7 @@ describe 'abusive scopes validation' do it 'allows only approved scopes' do - described_class::ALLOWED_SCOPES.each do |scope| + Search::Scopes.all_scope_names.each do |scope| expect(described_class.new({ scope: scope })).to be_valid end end diff --git a/spec/lib/search/scopes_spec.rb b/spec/lib/search/scopes_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..79c2d54600f4507e048fda4ab8401aabe6fb19f9 --- /dev/null +++ b/spec/lib/search/scopes_spec.rb @@ -0,0 +1,276 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Search::Scopes, feature_category: :global_search do + # Since tests run in EE environment by default, we need to explicitly check + # We'll mark CE-specific tests with stub_ee(false) where needed + + describe '.all_scope_names' do + it 'returns all defined scope names as strings' do + scope_names = described_class.all_scope_names + + expect(scope_names).to include('projects', 'issues', 'merge_requests', 'blobs', 'users') + expect(scope_names).to all(be_a(String)) + end + end + + describe '.available_for_context' do + context 'for global context' do + it 'returns scopes available for global search' do + scopes = described_class.available_for_context(context: :global, search_type: :basic) + + expect(scopes).to include('projects', 'issues', 'merge_requests', 'milestones', 'users') + expect(scopes).not_to include('blobs') # requires advanced search for global + end + + it 'includes code scope when search_type is advanced' do + scopes = described_class.available_for_context(context: :global, search_type: :advanced) + + expect(scopes).to include('blobs', 'commits', 'wiki_blobs', 'notes') + end + + it 'includes EE scopes in EE environment', if: Gitlab.ee? do + scopes = described_class.available_for_context(context: :global, search_type: :advanced) + + expect(scopes).to include('epics') + end + end + + context 'for project context' do + it 'returns scopes available for project search' do + scopes = described_class.available_for_context(context: :project, search_type: :basic) + + expect(scopes).to include('blobs', 'issues', 'merge_requests', 'wiki_blobs', 'commits', 'notes', 'milestones', + 'users') + expect(scopes).not_to include('projects') # not available in project context + end + + it 'includes code scope even in basic search for projects' do + scopes = described_class.available_for_context(context: :project, search_type: :basic) + + expect(scopes).to include('blobs') + end + + it 'excludes epics in project context' do + scopes = described_class.available_for_context(context: :project, search_type: :advanced) + + expect(scopes).not_to include('epics') + end + end + + context 'for group context' do + it 'returns scopes available for group search' do + scopes = described_class.available_for_context(context: :group, search_type: :advanced) + + expect(scopes).to include('projects', 'issues', 'merge_requests', 'blobs') + expect(scopes).to include('milestones', 'users') + end + + it 'includes epics in group context on EE', if: Gitlab.ee? do + scopes = described_class.available_for_context(context: :group, search_type: :advanced) + + expect(scopes).to include('epics') + end + end + end + + describe '.valid_scope?' do + it 'returns true for valid CE scopes' do + expect(described_class.valid_scope?('projects')).to be true + expect(described_class.valid_scope?('issues')).to be true + expect(described_class.valid_scope?('blobs')).to be true + end + + it 'returns false for invalid scopes' do + expect(described_class.valid_scope?('invalid_scope')).to be false + expect(described_class.valid_scope?('')).to be false + expect(described_class.valid_scope?(nil)).to be false + end + + it 'returns true for EE scopes on EE instance', if: Gitlab.ee? do + expect(described_class.valid_scope?('epics')).to be true + end + + context 'with context parameter' do + it 'validates scope is available in given context' do + expect(described_class.valid_scope?('projects', context: :global)).to be true + expect(described_class.valid_scope?('projects', context: :project)).to be false + end + + it 'validates blobs scope for different contexts' do + expect(described_class.valid_scope?('blobs', context: :project)).to be true + expect(described_class.valid_scope?('blobs', context: :global)).to be true + end + end + end + + describe '.enabled_scopes' do + let(:settings) { instance_double(ApplicationSetting) } + + before do + # Stub CE settings + allow(settings).to receive_messages( + global_search_issues_enabled?: true, + global_search_merge_requests_enabled?: true, + global_search_snippet_titles_enabled?: true, + global_search_users_enabled?: true + ) + + # Stub EE settings (if running in EE) + if Gitlab.ee? + allow(settings).to receive_messages(global_search_code_enabled?: true, global_search_wiki_enabled?: true, + global_search_commits_enabled?: true, global_search_epics_enabled?: true) + end + end + + context 'for global context' do + it 'returns only enabled scopes based on settings' do + allow(settings).to receive_messages(global_search_issues_enabled?: true, + global_search_merge_requests_enabled?: false, global_search_users_enabled?: true) + + allow(settings).to receive(:global_search_code_enabled?).and_return(false) if Gitlab.ee? + + scopes = described_class.enabled_scopes(context: :global, settings: settings) + + expect(scopes).to include('issues', 'users', 'projects', 'milestones') + expect(scopes).not_to include('merge_requests') + end + end + + context 'for project context' do + it 'returns all available scopes regardless of global settings' do + allow(settings).to receive_messages(global_search_issues_enabled?: false, + global_search_merge_requests_enabled?: false) + + scopes = described_class.enabled_scopes(context: :project, settings: settings) + + expect(scopes).to include('issues', 'merge_requests', 'blobs') + end + end + end + + describe '.scope_enabled_for_global_search?' do + let(:settings) { instance_double(ApplicationSetting) } + + before do + allow(settings).to receive_messages( + global_search_issues_enabled?: true, + global_search_merge_requests_enabled?: true, + global_search_snippet_titles_enabled?: true, + global_search_users_enabled?: true + ) + end + + it 'returns true for scopes without settings check' do + expect(described_class.scope_enabled_for_global_search?('projects', settings, :global)).to be true + expect(described_class.scope_enabled_for_global_search?('milestones', settings, :global)).to be true + end + + it 'checks application settings for scopes with settings' do + allow(settings).to receive(:global_search_issues_enabled?).and_return(false) + + expect(described_class.scope_enabled_for_global_search?('issues', settings, :global)).to be false + end + + it 'returns true for project context regardless of global settings' do + allow(settings).to receive(:global_search_issues_enabled?).and_return(false) + + expect(described_class.scope_enabled_for_global_search?('issues', settings, :project)).to be true + end + end + + describe '.label_for' do + it 'returns the human-readable label for a scope' do + expect(described_class.label_for('projects')).to eq('Projects') + expect(described_class.label_for('blobs')).to eq('Code') + expect(described_class.label_for('merge_requests')).to eq('Merge requests') + end + + it 'returns nil for invalid scope' do + expect(described_class.label_for('invalid')).to be_nil + end + end + + describe '.default_scope_options' do + it 'returns array of hashes with value and text' do + options = described_class.default_scope_options + + expect(options).to be_an(Array) + expect(options.first).to have_key(:value) + expect(options.first).to have_key(:text) + end + + it 'sorts options by defined sort order' do + options = described_class.default_scope_options + values = options.pluck(:value) + + # Projects (sort: 1) should come before Issues (sort: 4) + expect(values.index('projects')).to be < values.index('issues') + end + + it 'includes only scopes available on CE' do + options = described_class.default_scope_options + values = options.pluck(:value) + + expect(values).to include('projects', 'issues', 'users') + expect(values).not_to include('epics') + end + end + + describe '.validate_default_scope' do + let(:settings) { instance_double(ApplicationSetting) } + + before do + allow(settings).to receive_messages( + global_search_issues_enabled?: true, + global_search_merge_requests_enabled?: true, + global_search_snippet_titles_enabled?: true, + global_search_users_enabled?: true + ) + + if Gitlab.ee? + allow(settings).to receive_messages( + global_search_code_enabled?: true, + global_search_wiki_enabled?: true, + global_search_commits_enabled?: true, + global_search_epics_enabled?: true + ) + end + end + + it 'returns empty array for valid scope' do + errors = described_class.validate_default_scope('projects', settings) + + expect(errors).to be_empty + end + + it 'returns empty array for blank scope' do + errors = described_class.validate_default_scope('', settings) + + expect(errors).to be_empty + end + + it 'returns error for invalid scope' do + errors = described_class.validate_default_scope('invalid_scope', settings) + + expect(errors).to include(match(/is not a valid search scope/)) + end + + it 'returns error when scope is disabled by settings' do + allow(settings).to receive(:global_search_issues_enabled?).and_return(false) + errors = described_class.validate_default_scope('issues', settings) + + expect(errors).to include(match(/currently disabled/)) + expect(errors.first).to include('Global search issues enabled') + end + + it 'validates EE scopes correctly on EE instance', if: Gitlab.ee? do + allow(settings).to receive(:global_search_epics_enabled?).and_return(false) + errors = described_class.validate_default_scope('epics', settings) + + expect(errors).not_to be_empty + expect(errors).to include(match(/currently disabled/)) + end + end +end