From 692f4b734f1976b690dccb5458c198b5205c51b5 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Thu, 3 Sep 2020 21:08:18 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .rubocop.yml | 3 + .rubocop_todo.yml | 19 +- .../behaviors/shortcuts/shortcuts.js | 6 +- .../shortcuts/shortcuts_find_file.js | 17 +- .../javascripts/pages/search/show/index.js | 6 +- .../state_filter/components/state_filter.vue | 91 ++++ .../search/state_filter/constants.js | 20 + .../javascripts/search/state_filter/index.js | 34 ++ .../components/file_finder/index.vue | 27 +- .../filtered_search_bar_root.vue | 39 +- .../filtered_search_utils.js | 32 +- .../merge_requests/diffs_controller.rb | 1 - .../projects/merge_requests_controller.rb | 2 + app/finders/issues_finder.rb | 2 +- app/helpers/lazy_image_tag_helper.rb | 2 +- app/helpers/search_helper.rb | 2 +- app/models/operations/feature_flag.rb | 101 +++++ app/models/operations/feature_flag_scope.rb | 62 +++ app/models/operations/feature_flags/scope.rb | 13 + .../operations/feature_flags/strategy.rb | 94 +++++ .../feature_flags/strategy_user_list.rb | 12 + .../operations/feature_flags/user_list.rb | 36 ++ app/models/operations/feature_flags_client.rb | 25 ++ app/models/project.rb | 4 + app/services/search/global_service.rb | 3 +- app/services/search/group_service.rb | 3 +- app/services/search/project_service.rb | 3 +- .../feature_flag_strategies_validator.rb | 95 +++++ .../feature_flag_user_xids_validator.rb | 31 ++ app/views/search/_results.html.haml | 2 + ...nt-issue-scope-results-filter-by-state.yml | 5 + changelogs/unreleased/access-modifier-cop.yml | 5 + config/dependency_decisions.yml | 6 + config/routes/project.rb | 8 + .../geo/disaster_recovery/planned_failover.md | 2 +- .../graphql/reference/gitlab_schema.graphql | 5 + doc/api/graphql/reference/gitlab_schema.json | 10 + doc/api/groups.md | 22 + doc/api/markdown.md | 4 +- doc/ci/yaml/README.md | 3 +- doc/development/application_limits.md | 88 ++-- .../documentation/feature_flags.md | 8 +- doc/development/documentation/styleguide.md | 5 +- .../browser_performance_testing.md | 8 +- .../img/browser_performance_testing.png | Bin 26201 -> 40417 bytes lib/backup/database.rb | 11 +- lib/gitlab/cache/request_cache.rb | 2 +- .../Browser-Performance-Testing.gitlab-ci.yml | 8 +- .../Verify/Browser-Performance.gitlab-ci.yml | 6 +- lib/gitlab/group_search_results.rb | 4 +- lib/gitlab/middleware/multipart.rb | 4 +- lib/gitlab/project_search_results.rb | 4 +- lib/gitlab/regex.rb | 11 +- lib/gitlab/request_profiler.rb | 8 +- lib/gitlab/search_results.rb | 7 +- lib/uploaded_file.rb | 3 +- locale/gitlab.pot | 3 + package.json | 4 +- .../merge_requests/diffs_controller_spec.rb | 35 -- .../merge_requests_controller_spec.rb | 10 + .../clusters/kubernetes_namespaces.rb | 15 +- spec/factories/draft_note.rb | 2 +- spec/factories/file_uploaders.rb | 2 +- .../operations/feature_flag_scopes.rb | 10 + spec/factories/operations/feature_flags.rb | 17 + .../operations/feature_flags/scope.rb | 8 + .../operations/feature_flags/strategy.rb | 9 + .../operations/feature_flags/user_list.rb | 9 + .../operations/feature_flags_clients.rb | 7 + .../search/components/state_filter_spec.js | 81 ++++ .../components/file_finder/index_spec.js | 18 +- .../filtered_search_bar_root_spec.js | 114 ++--- .../filtered_search_utils_spec.js | 33 ++ .../filtered_search_bar/mock_data.js | 60 +-- .../tokens/milestone_token_spec.js | 2 +- spec/lib/gitlab/group_search_results_spec.rb | 17 +- .../middleware/multipart/handler_spec.rb | 53 +++ spec/lib/gitlab/middleware/multipart_spec.rb | 347 ++++------------ .../lib/gitlab/project_search_results_spec.rb | 25 +- spec/lib/gitlab/search_results_spec.rb | 24 +- spec/lib/uploaded_file_spec.rb | 2 +- .../operations/feature_flag_scope_spec.rb | 391 ++++++++++++++++++ spec/models/operations/feature_flag_spec.rb | 258 ++++++++++++ .../operations/feature_flags/strategy_spec.rb | 323 +++++++++++++++ .../feature_flags/user_list_spec.rb | 102 +++++ .../operations/feature_flags_client_spec.rb | 21 + .../list_projects_service_spec.rb | 2 +- spec/support/forgery_protection.rb | 2 +- spec/support/helpers/feature_flag_helpers.rb | 95 +++++ spec/support/helpers/multipart_helpers.rb | 82 ++++ .../middleware/multipart_shared_contexts.rb | 106 +++-- .../middleware/multipart_shared_examples.rb | 145 +++++++ ...arch_issue_state_filter_shared_examples.rb | 48 +++ spec/views/search/_results.html.haml_spec.rb | 6 + yarn.lock | 16 +- 95 files changed, 2950 insertions(+), 588 deletions(-) create mode 100644 app/assets/javascripts/search/state_filter/components/state_filter.vue create mode 100644 app/assets/javascripts/search/state_filter/constants.js create mode 100644 app/assets/javascripts/search/state_filter/index.js create mode 100644 app/models/operations/feature_flag.rb create mode 100644 app/models/operations/feature_flag_scope.rb create mode 100644 app/models/operations/feature_flags/scope.rb create mode 100644 app/models/operations/feature_flags/strategy.rb create mode 100644 app/models/operations/feature_flags/strategy_user_list.rb create mode 100644 app/models/operations/feature_flags/user_list.rb create mode 100644 app/models/operations/feature_flags_client.rb create mode 100644 app/validators/feature_flag_strategies_validator.rb create mode 100644 app/validators/feature_flag_user_xids_validator.rb create mode 100644 changelogs/unreleased/237932-search-ui-implement-issue-scope-results-filter-by-state.yml create mode 100644 changelogs/unreleased/access-modifier-cop.yml create mode 100644 spec/factories/operations/feature_flag_scopes.rb create mode 100644 spec/factories/operations/feature_flags.rb create mode 100644 spec/factories/operations/feature_flags/scope.rb create mode 100644 spec/factories/operations/feature_flags/strategy.rb create mode 100644 spec/factories/operations/feature_flags/user_list.rb create mode 100644 spec/factories/operations/feature_flags_clients.rb create mode 100644 spec/frontend/search/components/state_filter_spec.js create mode 100644 spec/lib/gitlab/middleware/multipart/handler_spec.rb create mode 100644 spec/models/operations/feature_flag_scope_spec.rb create mode 100644 spec/models/operations/feature_flag_spec.rb create mode 100644 spec/models/operations/feature_flags/strategy_spec.rb create mode 100644 spec/models/operations/feature_flags/user_list_spec.rb create mode 100644 spec/models/operations/feature_flags_client_spec.rb create mode 100644 spec/support/helpers/feature_flag_helpers.rb create mode 100644 spec/support/helpers/multipart_helpers.rb create mode 100644 spec/support/shared_examples/lib/gitlab/middleware/multipart_shared_examples.rb create mode 100644 spec/support/shared_examples/lib/gitlab/search_issue_state_filter_shared_examples.rb diff --git a/.rubocop.yml b/.rubocop.yml index 30046ac1b90..2d3afe3a8aa 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -60,6 +60,9 @@ Style/MutableConstant: Style/SafeNavigation: Enabled: false +Style/AccessModifierDeclarations: + AllowModifiersOnSymbols: true + # Frozen String Literal Style/FrozenStringLiteralComment: Enabled: true diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 4776936ccce..8c43f0c1d6e 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -498,17 +498,6 @@ Security/YAMLLoad: - 'spec/initializers/secret_token_spec.rb' - 'spec/lib/gitlab/prometheus/additional_metrics_parser_spec.rb' -# Offense count: 10 -# Configuration parameters: EnforcedStyle, AllowModifiersOnSymbols. -# SupportedStyles: inline, group -Style/AccessModifierDeclarations: - Exclude: - - 'app/helpers/issues_helper.rb' - - 'app/helpers/lazy_image_tag_helper.rb' - - 'lib/gitlab/cache/request_cache.rb' - - 'lib/gitlab/request_profiler.rb' - - 'spec/support/forgery_protection.rb' - # Offense count: 148 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle. @@ -741,10 +730,6 @@ Rails/SaveBang: - 'ee/spec/models/license_spec.rb' - 'ee/spec/models/merge_request_spec.rb' - 'ee/spec/models/merge_train_spec.rb' - - 'ee/spec/models/operations/feature_flag_scope_spec.rb' - - 'ee/spec/models/operations/feature_flag_spec.rb' - - 'ee/spec/models/operations/feature_flags/strategy_spec.rb' - - 'ee/spec/models/operations/feature_flags/user_list_spec.rb' - 'spec/models/packages/package_spec.rb' - 'ee/spec/models/project_ci_cd_setting_spec.rb' - 'ee/spec/models/project_services/github_service_spec.rb' @@ -1124,6 +1109,10 @@ Rails/SaveBang: - 'spec/models/namespace_spec.rb' - 'spec/models/note_spec.rb' - 'spec/models/notification_setting_spec.rb' + - 'spec/models/operations/feature_flag_scope_spec.rb' + - 'spec/models/operations/feature_flag_spec.rb' + - 'spec/models/operations/feature_flags/strategy_spec.rb' + - 'spec/models/operations/feature_flags/user_list_spec.rb' - 'spec/models/pages_domain_spec.rb' - 'spec/models/project_auto_devops_spec.rb' - 'spec/models/project_feature_spec.rb' diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js index e4e5f16927b..f820396d05b 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js @@ -9,13 +9,13 @@ import { refreshCurrentPage, visitUrl } from '../../lib/utils/url_utility'; import findAndFollowLink from '../../lib/utils/navigation_utility'; import { parseBoolean, getCspNonceValue } from '~/lib/utils/common_utils'; -const defaultStopCallback = Mousetrap.stopCallback; -Mousetrap.stopCallback = (e, element, combo) => { +const defaultStopCallback = Mousetrap.prototype.stopCallback; +Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) { if (['ctrl+shift+p', 'command+shift+p'].indexOf(combo) !== -1) { return false; } - return defaultStopCallback(e, element, combo); + return defaultStopCallback.call(this, e, element, combo); }; function initToggleButton() { diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js index 8658081c6c2..f0d2ecfd210 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js @@ -5,12 +5,11 @@ export default class ShortcutsFindFile extends ShortcutsNavigation { constructor(projectFindFile) { super(); - const oldStopCallback = Mousetrap.stopCallback; - this.projectFindFile = projectFindFile; + const oldStopCallback = Mousetrap.prototype.stopCallback; - Mousetrap.stopCallback = (e, element, combo) => { + Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) { if ( - element === this.projectFindFile.inputElement[0] && + element === projectFindFile.inputElement[0] && (combo === 'up' || combo === 'down' || combo === 'esc' || combo === 'enter') ) { // when press up/down key in textbox, cursor prevent to move to home/end @@ -18,12 +17,12 @@ export default class ShortcutsFindFile extends ShortcutsNavigation { return false; } - return oldStopCallback(e, element, combo); + return oldStopCallback.call(this, e, element, combo); }; - Mousetrap.bind('up', this.projectFindFile.selectRowUp); - Mousetrap.bind('down', this.projectFindFile.selectRowDown); - Mousetrap.bind('esc', this.projectFindFile.goToTree); - Mousetrap.bind('enter', this.projectFindFile.goToBlob); + Mousetrap.bind('up', projectFindFile.selectRowUp); + Mousetrap.bind('down', projectFindFile.selectRowDown); + Mousetrap.bind('esc', projectFindFile.goToTree); + Mousetrap.bind('enter', projectFindFile.goToBlob); } } diff --git a/app/assets/javascripts/pages/search/show/index.js b/app/assets/javascripts/pages/search/show/index.js index 85aaaa2c9da..92d01343bd5 100644 --- a/app/assets/javascripts/pages/search/show/index.js +++ b/app/assets/javascripts/pages/search/show/index.js @@ -1,3 +1,7 @@ import Search from './search'; +import initStateFilter from '~/search/state_filter'; -document.addEventListener('DOMContentLoaded', () => new Search()); +document.addEventListener('DOMContentLoaded', () => { + initStateFilter(); + return new Search(); +}); diff --git a/app/assets/javascripts/search/state_filter/components/state_filter.vue b/app/assets/javascripts/search/state_filter/components/state_filter.vue new file mode 100644 index 00000000000..5245c23843e --- /dev/null +++ b/app/assets/javascripts/search/state_filter/components/state_filter.vue @@ -0,0 +1,91 @@ + + + diff --git a/app/assets/javascripts/search/state_filter/constants.js b/app/assets/javascripts/search/state_filter/constants.js new file mode 100644 index 00000000000..25728486360 --- /dev/null +++ b/app/assets/javascripts/search/state_filter/constants.js @@ -0,0 +1,20 @@ +import { __ } from '~/locale'; + +export const FILTER_HEADER = __('Status'); + +export const FILTER_TEXT = __('Any Status'); + +export const FILTER_STATES = { + ANY: { + label: __('Any'), + value: 'all', + }, + OPEN: { + label: __('Open'), + value: 'opened', + }, + CLOSED: { + label: __('Closed'), + value: 'closed', + }, +}; diff --git a/app/assets/javascripts/search/state_filter/index.js b/app/assets/javascripts/search/state_filter/index.js new file mode 100644 index 00000000000..13708574cfb --- /dev/null +++ b/app/assets/javascripts/search/state_filter/index.js @@ -0,0 +1,34 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import StateFilter from './components/state_filter.vue'; + +Vue.use(Translate); + +export default () => { + const el = document.getElementById('js-search-filter-by-state'); + + if (!el) return false; + + return new Vue({ + el, + components: { + StateFilter, + }, + data() { + const { dataset } = this.$options.el; + return { + scope: dataset.scope, + state: dataset.state, + }; + }, + + render(createElement) { + return createElement('state-filter', { + props: { + scope: this.scope, + state: this.state, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue index d6f591ccca1..71d38ad4c42 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -9,7 +9,7 @@ export const MAX_FILE_FINDER_RESULTS = 40; export const FILE_FINDER_ROW_HEIGHT = 55; export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33; -const originalStopCallback = Mousetrap.stopCallback; +const originalStopCallback = Mousetrap.prototype.stopCallback; export default { components: { @@ -134,7 +134,18 @@ export default { this.toggle(!this.visible); }); - Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo); + Mousetrap.prototype.stopCallback = function customStopCallback(e, el, combo) { + if ( + (combo === 't' && el.classList.contains('dropdown-input-field')) || + el.classList.contains('inputarea') + ) { + return true; + } else if (combo === 'command+p' || combo === 'ctrl+p') { + return false; + } + + return originalStopCallback.call(this, e, el, combo); + }; }, methods: { toggle(visible) { @@ -199,18 +210,6 @@ export default { this.cancelMouseOver = false; this.onMouseOver(index); }, - mousetrapStopCallback(e, el, combo) { - if ( - (combo === 't' && el.classList.contains('dropdown-input-field')) || - el.classList.contains('inputarea') - ) { - return true; - } else if (combo === 'command+p' || combo === 'ctrl+p') { - return false; - } - - return originalStopCallback(e, el, combo); - }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index ee293d37b66..dae7c921988 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -15,7 +15,7 @@ import { deprecatedCreateFlash as createFlash } from '~/flash'; import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; -import { stripQuotes } from './filtered_search_utils'; +import { stripQuotes, uniqueTokens } from './filtered_search_utils'; import { SortDirection } from './constants'; export default { @@ -120,10 +120,31 @@ export default { ? __('Sort direction: Ascending') : __('Sort direction: Descending'); }, + /** + * This prop fixes a behaviour affecting GlFilteredSearch + * where selecting duplicate token values leads to history + * dropdown also showing that selection. + */ filteredRecentSearches() { - return this.recentSearchesStorageKey - ? this.recentSearches.filter(item => typeof item !== 'string') - : undefined; + if (this.recentSearchesStorageKey) { + const knownItems = []; + return this.recentSearches.reduce((historyItems, item) => { + // Only include non-string history items (discard items from legacy search) + if (typeof item !== 'string') { + const sanitizedItem = uniqueTokens(item); + const itemString = JSON.stringify(sanitizedItem); + // Only include items which aren't already part of history + if (!knownItems.includes(itemString)) { + historyItems.push(sanitizedItem); + // We're storing string for comparision as doing direct object compare + // won't work due to object reference not being the same. + knownItems.push(itemString); + } + } + return historyItems; + }, []); + } + return undefined; }, }, watch: { @@ -245,12 +266,14 @@ export default { this.recentSearchesService.save(resultantSearches); this.recentSearches = []; }, - handleFilterSubmit(filters) { + handleFilterSubmit() { + const filterTokens = uniqueTokens(this.filterValue); + this.filterValue = filterTokens; if (this.recentSearchesStorageKey) { this.recentSearchesPromise .then(() => { - if (filters.length) { - const resultantSearches = this.recentSearchesStore.addRecentSearch(filters); + if (filterTokens.length) { + const resultantSearches = this.recentSearchesStore.addRecentSearch(filterTokens); this.recentSearchesService.save(resultantSearches); this.recentSearches = resultantSearches; } @@ -260,7 +283,7 @@ export default { }); } this.blurSearchInput(); - this.$emit('onFilter', this.removeQuotesEnclosure(filters)); + this.$emit('onFilter', this.removeQuotesEnclosure(filterTokens)); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js index 4a5b8668198..a981c67e7be 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js @@ -1,3 +1,31 @@ -export const stripQuotes = value => { - return value.includes(' ') ? value.slice(1, -1) : value; +/** + * Strips enclosing quotations from a string if it has one. + * + * @param {String} value String to strip quotes from + * + * @returns {String} String without any enclosure + */ +export const stripQuotes = value => value.replace(/^('|")(.*)('|")$/, '$2'); + +/** + * This method removes duplicate tokens from tokens array. + * + * @param {Array} tokens Array of tokens as defined by `GlFilteredSearch` + * + * @returns {Array} Unique array of tokens + */ +export const uniqueTokens = tokens => { + const knownTokens = []; + return tokens.reduce((uniques, token) => { + if (typeof token === 'object' && token.type !== 'filtered-search-term') { + const tokenString = `${token.type}${token.value.operator}${token.value.data}`; + if (!knownTokens.includes(tokenString)) { + uniques.push(token); + knownTokens.push(tokenString); + } + } else { + uniques.push(token); + } + return uniques; + }, []); }; diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index b8f14a82d96..1b18e5c80be 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -4,7 +4,6 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic include DiffHelper include RendersNotes - before_action :apply_diff_view_cookie! before_action :commit before_action :define_diff_vars before_action :define_diff_comment_vars, except: [:diffs_batch, :diffs_metadata] diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 35f79f721f5..14a8a4c5961 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -10,8 +10,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo include IssuableCollections include RecordUserLastActivity include SourcegraphDecorator + include DiffHelper skip_before_action :merge_request, only: [:index, :bulk_update] + before_action :apply_diff_view_cookie!, only: [:show] before_action :whitelist_query_limiting, only: [:assign_related_issues, :update] before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort] before_action :authorize_read_actual_head_pipeline!, only: [ diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index bbb624f543b..263cd245436 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -8,7 +8,7 @@ # current_user - which user use # params: # scope: 'created_by_me' or 'assigned_to_me' or 'all' -# state: 'open' or 'closed' or 'all' +# state: 'opened' or 'closed' or 'all' # group_id: integer # project_id: integer # milestone_title: string diff --git a/app/helpers/lazy_image_tag_helper.rb b/app/helpers/lazy_image_tag_helper.rb index ac987a04895..0c5744b46ae 100644 --- a/app/helpers/lazy_image_tag_helper.rb +++ b/app/helpers/lazy_image_tag_helper.rb @@ -25,5 +25,5 @@ module LazyImageTagHelper end # Required for Banzai::Filter::ImageLazyLoadFilter - module_function :placeholder_image + module_function :placeholder_image # rubocop: disable Style/AccessModifierDeclarations end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 61e0fe19c77..9f3623ad511 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module SearchHelper - SEARCH_PERMITTED_PARAMS = [:search, :scope, :project_id, :group_id, :repository_ref, :snippets].freeze + SEARCH_PERMITTED_PARAMS = [:search, :scope, :project_id, :group_id, :repository_ref, :snippets, :state].freeze def search_autocomplete_opts(term) return unless current_user diff --git a/app/models/operations/feature_flag.rb b/app/models/operations/feature_flag.rb new file mode 100644 index 00000000000..586e9d689a1 --- /dev/null +++ b/app/models/operations/feature_flag.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Operations + class FeatureFlag < ApplicationRecord + include AtomicInternalId + include IidRoutes + + self.table_name = 'operations_feature_flags' + + belongs_to :project + + has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.operations_feature_flags&.maximum(:iid) } + + default_value_for :active, true + + # scopes exists only for the first version + has_many :scopes, class_name: 'Operations::FeatureFlagScope' + # strategies exists only for the second version + has_many :strategies, class_name: 'Operations::FeatureFlags::Strategy' + has_many :feature_flag_issues + has_many :issues, through: :feature_flag_issues + has_one :default_scope, -> { where(environment_scope: '*') }, class_name: 'Operations::FeatureFlagScope' + + validates :project, presence: true + validates :name, + presence: true, + length: 2..63, + format: { + with: Gitlab::Regex.feature_flag_regex, + message: Gitlab::Regex.feature_flag_regex_message + } + validates :name, uniqueness: { scope: :project_id } + validates :description, allow_blank: true, length: 0..255 + validate :first_default_scope, on: :create, if: :has_scopes? + validate :version_associations + + before_create :build_default_scope, if: -> { legacy_flag? && scopes.none? } + + accepts_nested_attributes_for :scopes, allow_destroy: true + accepts_nested_attributes_for :strategies, allow_destroy: true + + scope :ordered, -> { order(:name) } + + scope :enabled, -> { where(active: true) } + scope :disabled, -> { where(active: false) } + + enum version: { + legacy_flag: 1, + new_version_flag: 2 + } + + class << self + def preload_relations + preload(:scopes, strategies: :scopes) + end + + def for_unleash_client(project, environment) + includes(strategies: [:scopes, :user_list]) + .where(project: project) + .merge(Operations::FeatureFlags::Scope.on_environment(environment)) + .reorder(:id) + .references(:operations_scopes) + end + end + + def related_issues(current_user, preload:) + issues = ::Issue + .select('issues.*, operations_feature_flags_issues.id AS link_id') + .joins(:feature_flag_issues) + .where('operations_feature_flags_issues.feature_flag_id = ?', id) + .order('operations_feature_flags_issues.id ASC') + .includes(preload) + + Ability.issues_readable_by_user(issues, current_user) + end + + private + + def version_associations + if new_version_flag? && scopes.any? + errors.add(:version_associations, 'version 2 feature flags may not have scopes') + elsif legacy_flag? && strategies.any? + errors.add(:version_associations, 'version 1 feature flags may not have strategies') + end + end + + def first_default_scope + unless scopes.first.environment_scope == '*' + errors.add(:default_scope, 'has to be the first element') + end + end + + def build_default_scope + scopes.build(environment_scope: '*', active: self.active) + end + + def has_scopes? + scopes.any? + end + end +end diff --git a/app/models/operations/feature_flag_scope.rb b/app/models/operations/feature_flag_scope.rb new file mode 100644 index 00000000000..78be29f2531 --- /dev/null +++ b/app/models/operations/feature_flag_scope.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Operations + class FeatureFlagScope < ApplicationRecord + prepend HasEnvironmentScope + include Gitlab::Utils::StrongMemoize + + self.table_name = 'operations_feature_flag_scopes' + + belongs_to :feature_flag + + validates :environment_scope, uniqueness: { + scope: :feature_flag, + message: "(%{value}) has already been taken" + } + + validates :environment_scope, + if: :default_scope?, on: :update, + inclusion: { in: %w(*), message: 'cannot be changed from default scope' } + + validates :strategies, feature_flag_strategies: true + + before_destroy :prevent_destroy_default_scope, if: :default_scope? + + scope :ordered, -> { order(:id) } + scope :enabled, -> { where(active: true) } + scope :disabled, -> { where(active: false) } + + def self.with_name_and_description + joins(:feature_flag) + .select(FeatureFlag.arel_table[:name], FeatureFlag.arel_table[:description]) + end + + def self.for_unleash_client(project, environment) + select_columns = [ + 'DISTINCT ON (operations_feature_flag_scopes.feature_flag_id) operations_feature_flag_scopes.id', + '(operations_feature_flags.active AND operations_feature_flag_scopes.active) AS active', + 'operations_feature_flag_scopes.strategies', + 'operations_feature_flag_scopes.environment_scope', + 'operations_feature_flag_scopes.created_at', + 'operations_feature_flag_scopes.updated_at' + ] + + select(select_columns) + .with_name_and_description + .where(feature_flag_id: project.operations_feature_flags.select(:id)) + .order(:feature_flag_id) + .on_environment(environment) + .reverse_order + end + + private + + def default_scope? + environment_scope_was == '*' + end + + def prevent_destroy_default_scope + raise ActiveRecord::ReadOnlyRecord, "default scope cannot be destroyed" + end + end +end diff --git a/app/models/operations/feature_flags/scope.rb b/app/models/operations/feature_flags/scope.rb new file mode 100644 index 00000000000..d70101b5e0d --- /dev/null +++ b/app/models/operations/feature_flags/scope.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Operations + module FeatureFlags + class Scope < ApplicationRecord + prepend HasEnvironmentScope + + self.table_name = 'operations_scopes' + + belongs_to :strategy, class_name: 'Operations::FeatureFlags::Strategy' + end + end +end diff --git a/app/models/operations/feature_flags/strategy.rb b/app/models/operations/feature_flags/strategy.rb new file mode 100644 index 00000000000..ff68af9741e --- /dev/null +++ b/app/models/operations/feature_flags/strategy.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Operations + module FeatureFlags + class Strategy < ApplicationRecord + STRATEGY_DEFAULT = 'default' + STRATEGY_GITLABUSERLIST = 'gitlabUserList' + STRATEGY_GRADUALROLLOUTUSERID = 'gradualRolloutUserId' + STRATEGY_USERWITHID = 'userWithId' + STRATEGIES = { + STRATEGY_DEFAULT => [].freeze, + STRATEGY_GITLABUSERLIST => [].freeze, + STRATEGY_GRADUALROLLOUTUSERID => %w[groupId percentage].freeze, + STRATEGY_USERWITHID => ['userIds'].freeze + }.freeze + USERID_MAX_LENGTH = 256 + + self.table_name = 'operations_strategies' + + belongs_to :feature_flag + has_many :scopes, class_name: 'Operations::FeatureFlags::Scope' + has_one :strategy_user_list + has_one :user_list, through: :strategy_user_list + + validates :name, + inclusion: { + in: STRATEGIES.keys, + message: 'strategy name is invalid' + } + + validate :parameters_validations, if: -> { errors[:name].blank? } + validates :user_list, presence: true, if: -> { name == STRATEGY_GITLABUSERLIST } + validates :user_list, absence: true, if: -> { name != STRATEGY_GITLABUSERLIST } + validate :same_project_validation, if: -> { user_list.present? } + + accepts_nested_attributes_for :scopes, allow_destroy: true + + def user_list_id=(user_list_id) + self.user_list = ::Operations::FeatureFlags::UserList.find(user_list_id) + end + + private + + def same_project_validation + unless user_list.project_id == feature_flag.project_id + errors.add(:user_list, 'must belong to the same project') + end + end + + def parameters_validations + validate_parameters_type && + validate_parameters_keys && + validate_parameters_values + end + + def validate_parameters_type + parameters.is_a?(Hash) || parameters_error('parameters are invalid') + end + + def validate_parameters_keys + actual_keys = parameters.keys.sort + expected_keys = STRATEGIES[name].sort + expected_keys == actual_keys || parameters_error('parameters are invalid') + end + + def validate_parameters_values + case name + when STRATEGY_GRADUALROLLOUTUSERID + gradual_rollout_user_id_parameters_validation + when STRATEGY_USERWITHID + FeatureFlagUserXidsValidator.validate_user_xids(self, :parameters, parameters['userIds'], 'userIds') + end + end + + def gradual_rollout_user_id_parameters_validation + percentage = parameters['percentage'] + group_id = parameters['groupId'] + + unless percentage.is_a?(String) && percentage.match(/\A[1-9]?[0-9]\z|\A100\z/) + parameters_error('percentage must be a string between 0 and 100 inclusive') + end + + unless group_id.is_a?(String) && group_id.match(/\A[a-z]{1,32}\z/) + parameters_error('groupId parameter is invalid') + end + end + + def parameters_error(message) + errors.add(:parameters, message) + false + end + end + end +end diff --git a/app/models/operations/feature_flags/strategy_user_list.rb b/app/models/operations/feature_flags/strategy_user_list.rb new file mode 100644 index 00000000000..813b632dd67 --- /dev/null +++ b/app/models/operations/feature_flags/strategy_user_list.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Operations + module FeatureFlags + class StrategyUserList < ApplicationRecord + self.table_name = 'operations_strategies_user_lists' + + belongs_to :strategy + belongs_to :user_list + end + end +end diff --git a/app/models/operations/feature_flags/user_list.rb b/app/models/operations/feature_flags/user_list.rb new file mode 100644 index 00000000000..b9bdcb59d5f --- /dev/null +++ b/app/models/operations/feature_flags/user_list.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Operations + module FeatureFlags + class UserList < ApplicationRecord + include AtomicInternalId + include IidRoutes + + self.table_name = 'operations_user_lists' + + belongs_to :project + has_many :strategy_user_lists + has_many :strategies, through: :strategy_user_lists + + has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.operations_feature_flags_user_lists&.maximum(:iid) }, presence: true + + validates :project, presence: true + validates :name, + presence: true, + uniqueness: { scope: :project_id }, + length: 1..255 + validates :user_xids, feature_flag_user_xids: true + + before_destroy :ensure_no_associated_strategies + + private + + def ensure_no_associated_strategies + if strategies.present? + errors.add(:base, 'User list is associated with a strategy') + throw :abort # rubocop: disable Cop/BanCatchThrow + end + end + end + end +end diff --git a/app/models/operations/feature_flags_client.rb b/app/models/operations/feature_flags_client.rb new file mode 100644 index 00000000000..1c65c3f096e --- /dev/null +++ b/app/models/operations/feature_flags_client.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Operations + class FeatureFlagsClient < ApplicationRecord + include TokenAuthenticatable + + self.table_name = 'operations_feature_flags_clients' + + belongs_to :project + + validates :project, presence: true + validates :token, presence: true + + add_authentication_token_field :token, encrypted: :required + + before_validation :ensure_token! + + def self.find_for_project_and_token(project, token) + return unless project + return unless token + + where(project_id: project).find_by_token(token) + end + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 89dda183788..a5317d14dd8 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -344,6 +344,10 @@ class Project < ApplicationRecord # Issue https://gitlab.com/gitlab-org/gitlab/-/issues/228637 has_many :product_analytics_events, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :operations_feature_flags, class_name: 'Operations::FeatureFlag' + has_one :operations_feature_flags_client, class_name: 'Operations::FeatureFlagsClient' + has_many :operations_feature_flags_user_lists, class_name: 'Operations::FeatureFlags::UserList' + accepts_nested_attributes_for :variables, allow_destroy: true accepts_nested_attributes_for :project_feature, update_only: true accepts_nested_attributes_for :project_setting, update_only: true diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index 89f1ec6863b..fab02697cf0 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -13,7 +13,8 @@ module Search def execute Gitlab::SearchResults.new(current_user, params[:search], - projects) + projects, + filters: { state: params[:state] }) end def projects diff --git a/app/services/search/group_service.rb b/app/services/search/group_service.rb index 924716b8012..68778aa2768 100644 --- a/app/services/search/group_service.rb +++ b/app/services/search/group_service.rb @@ -15,7 +15,8 @@ module Search current_user, params[:search], projects, - group: group + group: group, + filters: { state: params[:state] } ) end diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index 6e52d59b038..5eba909c23b 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -12,7 +12,8 @@ module Search Gitlab::ProjectSearchResults.new(current_user, params[:search], project: project, - repository_ref: params[:repository_ref]) + repository_ref: params[:repository_ref], + filters: { state: params[:state] }) end def scope diff --git a/app/validators/feature_flag_strategies_validator.rb b/app/validators/feature_flag_strategies_validator.rb new file mode 100644 index 00000000000..e542d52c50a --- /dev/null +++ b/app/validators/feature_flag_strategies_validator.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +class FeatureFlagStrategiesValidator < ActiveModel::EachValidator + STRATEGY_DEFAULT = 'default'.freeze + STRATEGY_GRADUALROLLOUTUSERID = 'gradualRolloutUserId'.freeze + STRATEGY_USERWITHID = 'userWithId'.freeze + # Order key names alphabetically + STRATEGIES = { + STRATEGY_DEFAULT => [].freeze, + STRATEGY_GRADUALROLLOUTUSERID => %w[groupId percentage].freeze, + STRATEGY_USERWITHID => ['userIds'].freeze + }.freeze + USERID_MAX_LENGTH = 256 + + def validate_each(record, attribute, value) + return unless value + + if value.is_a?(Array) && value.all? { |s| s.is_a?(Hash) } + value.each do |strategy| + strategy_validations(record, attribute, strategy) + end + else + error(record, attribute, 'must be an array of strategy hashes') + end + end + + private + + def strategy_validations(record, attribute, strategy) + validate_name(record, attribute, strategy) && + validate_parameters_type(record, attribute, strategy) && + validate_parameters_keys(record, attribute, strategy) && + validate_parameters_values(record, attribute, strategy) + end + + def validate_name(record, attribute, strategy) + STRATEGIES.key?(strategy['name']) || error(record, attribute, 'strategy name is invalid') + end + + def validate_parameters_type(record, attribute, strategy) + strategy['parameters'].is_a?(Hash) || error(record, attribute, 'parameters are invalid') + end + + def validate_parameters_keys(record, attribute, strategy) + name, parameters = strategy.values_at('name', 'parameters') + actual_keys = parameters.keys.sort + expected_keys = STRATEGIES[name] + expected_keys == actual_keys || error(record, attribute, 'parameters are invalid') + end + + def validate_parameters_values(record, attribute, strategy) + case strategy['name'] + when STRATEGY_GRADUALROLLOUTUSERID + gradual_rollout_user_id_parameters_validation(record, attribute, strategy) + when STRATEGY_USERWITHID + user_with_id_parameters_validation(record, attribute, strategy) + end + end + + def gradual_rollout_user_id_parameters_validation(record, attribute, strategy) + percentage = strategy.dig('parameters', 'percentage') + group_id = strategy.dig('parameters', 'groupId') + + unless percentage.is_a?(String) && percentage.match(/\A[1-9]?[0-9]\z|\A100\z/) + error(record, attribute, 'percentage must be a string between 0 and 100 inclusive') + end + + unless group_id.is_a?(String) && group_id.match(/\A[a-z]{1,32}\z/) + error(record, attribute, 'groupId parameter is invalid') + end + end + + def user_with_id_parameters_validation(record, attribute, strategy) + user_ids = strategy.dig('parameters', 'userIds') + unless user_ids.is_a?(String) && !user_ids.match(/[\n\r\t]|,,/) && valid_ids?(user_ids.split(",")) + error(record, attribute, "userIds must be a string of unique comma separated values each #{USERID_MAX_LENGTH} characters or less") + end + end + + def valid_ids?(user_ids) + user_ids.uniq.length == user_ids.length && + user_ids.all? { |id| valid_id?(id) } + end + + def valid_id?(user_id) + user_id.present? && + user_id.strip == user_id && + user_id.length <= USERID_MAX_LENGTH + end + + def error(record, attribute, msg) + record.errors.add(attribute, msg) + false + end +end diff --git a/app/validators/feature_flag_user_xids_validator.rb b/app/validators/feature_flag_user_xids_validator.rb new file mode 100644 index 00000000000..a840993a94b --- /dev/null +++ b/app/validators/feature_flag_user_xids_validator.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class FeatureFlagUserXidsValidator < ActiveModel::EachValidator + USERXID_MAX_LENGTH = 256 + + def validate_each(record, attribute, value) + self.class.validate_user_xids(record, attribute, value, attribute) + end + + class << self + def validate_user_xids(record, attribute, user_xids, error_message_attribute_name) + unless user_xids.is_a?(String) && !user_xids.match(/[\n\r\t]|,,/) && valid_xids?(user_xids.split(",")) + record.errors.add(attribute, + "#{error_message_attribute_name} must be a string of unique comma separated values each #{USERXID_MAX_LENGTH} characters or less") + end + end + + private + + def valid_xids?(user_xids) + user_xids.uniq.length == user_xids.length && + user_xids.all? { |xid| valid_xid?(xid) } + end + + def valid_xid?(user_xid) + user_xid.present? && + user_xid.strip == user_xid && + user_xid.length <= USERXID_MAX_LENGTH + end + end +end diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index 79f01c61833..e0dbb5135e9 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -22,6 +22,8 @@ = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } = render_if_exists 'shared/promotions/promote_advanced_search' + #js-search-filter-by-state{ 'v-cloak': true, data: { scope: @scope, state: params[:state] } } + .results.gl-mt-3 - if @scope == 'commits' %ul.content-list.commit-list diff --git a/changelogs/unreleased/237932-search-ui-implement-issue-scope-results-filter-by-state.yml b/changelogs/unreleased/237932-search-ui-implement-issue-scope-results-filter-by-state.yml new file mode 100644 index 00000000000..148bcab1524 --- /dev/null +++ b/changelogs/unreleased/237932-search-ui-implement-issue-scope-results-filter-by-state.yml @@ -0,0 +1,5 @@ +--- +title: Search UI Allow issue scope results filtering by state +merge_request: 39881 +author: +type: changed diff --git a/changelogs/unreleased/access-modifier-cop.yml b/changelogs/unreleased/access-modifier-cop.yml new file mode 100644 index 00000000000..b02825c2f7f --- /dev/null +++ b/changelogs/unreleased/access-modifier-cop.yml @@ -0,0 +1,5 @@ +--- +title: Fix Style/AccessModifierDeclarations co cop +merge_request: 41252 +author: Rajendra Kadam +type: fixed diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml index 9256b902634..2f0d9066a7a 100644 --- a/config/dependency_decisions.yml +++ b/config/dependency_decisions.yml @@ -313,3 +313,9 @@ :why: "https://github.com/cure53/DOMPurify/blob/main/LICENSE and https://gitlab.com/gitlab-org/gitlab/-/merge_requests/31928#note_346604841" :versions: [] :when: 2020-08-13 13:42:46.508082000 Z +- - :whitelist + - Apache-2.0 WITH LLVM-exception + - :who: Nathan Friend + :why: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40670#note_403946372 + :versions: [] + :when: 2020-08-28 15:01:59.329048917 Z diff --git a/config/routes/project.rb b/config/routes/project.rb index 8c9b1f7f5cd..4ba1f14222e 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -367,6 +367,14 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do post :mark_as_spam end end + + resources :feature_flags, param: :iid do + resources :feature_flag_issues, only: [:index, :create, :destroy], as: 'issues', path: 'issues' + end + resource :feature_flags_client, only: [] do + post :reset_token + end + resources :feature_flags_user_lists, param: :iid, only: [:new, :edit, :show] end # End of the /-/ scope. diff --git a/doc/administration/geo/disaster_recovery/planned_failover.md b/doc/administration/geo/disaster_recovery/planned_failover.md index a0cf263a762..366659ee892 100644 --- a/doc/administration/geo/disaster_recovery/planned_failover.md +++ b/doc/administration/geo/disaster_recovery/planned_failover.md @@ -54,7 +54,7 @@ gitlab-ctl promotion-preflight-checks You can run this command in `force` mode to promote to primary even if preflight checks fail: ```shell -sudo gitlab-ctl promotion-preflight-checks --force +sudo gitlab-ctl promote-to-primary-node --force ``` Each step is described in more detail below. diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 86bc0b57344..c1bc231a1e7 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -2913,6 +2913,11 @@ input DastOnDemandScanCreateInput { """ clientMutationId: String + """ + ID of the scanner profile to be used for the scan. + """ + dastScannerProfileId: DastScannerProfileID + """ ID of the site profile to be used for the scan. """ diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index b526f0f7361..eb8cad5c44c 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -7889,6 +7889,16 @@ }, "defaultValue": null }, + { + "name": "dastScannerProfileId", + "description": "ID of the scanner profile to be used for the scan.", + "type": { + "kind": "SCALAR", + "name": "DastScannerProfileID", + "ofType": null + }, + "defaultValue": null + }, { "name": "clientMutationId", "description": "A unique identifier for the client performing the mutation.", diff --git a/doc/api/groups.md b/doc/api/groups.md index f2dd9ab81b6..c1556fb8e79 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -1170,10 +1170,14 @@ DELETE /groups/:id/share/:group_id ## Push Rules **(STARTER)** +> Introduced in [GitLab Starter](https://about.gitlab.com/pricing/) 13.4. + ### Get group push rules **(STARTER)** Get the [push rules](../user/group/index.md#group-push-rules-starter) of a group. +Only available to group owners and administrators. + ```plaintext GET /groups/:id/push_rule ``` @@ -1215,6 +1219,8 @@ the `commit_committer_check` and `reject_unsigned_commits` parameters: Adds [push rules](../user/group/index.md#group-push-rules-starter) to the specified group. +Only available to group owners and administrators. + ```plaintext POST /groups/:id/push_rule ``` @@ -1260,6 +1266,8 @@ Response: Edit push rules for a specified group. +Only available to group owners and administrators. + ```plaintext PUT /groups/:id/push_rule ``` @@ -1300,3 +1308,17 @@ Response: "max_file_size": 100 } ``` + +### Delete group push rule **(STARTER)** + +Deletes the [push rules](../user/group/index.md#group-push-rules-starter) of a group. + +Only available to group owners and administrators. + +```plaintext +DELETE /groups/:id/push_rule +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) | diff --git a/doc/api/markdown.md b/doc/api/markdown.md index 4e5c8515126..e382ca6b7c8 100644 --- a/doc/api/markdown.md +++ b/doc/api/markdown.md @@ -20,8 +20,8 @@ POST /api/v4/markdown | Attribute | Type | Required | Description | | --------- | ------- | ------------- | ------------------------------------------ | | `text` | string | yes | The Markdown text to render | -| `gfm` | boolean | no (optional) | Render text using GitLab Flavored Markdown. Default is `false` | -| `project` | string | no (optional) | Use `project` as a context when creating references using GitLab Flavored Markdown. [Authentication](README.md#authentication) is required if a project is not public. | +| `gfm` | boolean | no | Render text using GitLab Flavored Markdown. Default is `false` | +| `project` | string | no | Use `project` as a context when creating references using GitLab Flavored Markdown. [Authentication](README.md#authentication) is required if a project is not public. | ```shell curl --header Content-Type:application/json --data '{"text":"Hello world! :tada:", "gfm":true, "project":"group_example/project_example"}' "https://gitlab.example.com/api/v4/markdown" diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 5fc4e85bbf8..bedf4cfa530 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -2975,7 +2975,8 @@ skip the download step. attached to the job when it [succeeds, fails, or always](#artifactswhen). The artifacts will be sent to GitLab after the job finishes and will -be available for download in the GitLab UI. +be available for download in the GitLab UI provided that the size is not +larger than the [maximum artifact size](../../user/gitlab_com/index.md#gitlab-cicd). [Read more about artifacts](../pipelines/job_artifacts.md). diff --git a/doc/development/application_limits.md b/doc/development/application_limits.md index 4d296451add..f96ed2e7f57 100644 --- a/doc/development/application_limits.md +++ b/doc/development/application_limits.md @@ -17,50 +17,50 @@ limits](https://about.gitlab.com/handbook/product/product-processes/#introducing ### Insert database plan limits -In the `plan_limits` table, you have to create a new column and insert the -limit values. It's recommended to create separate migration script files. - -1. Add new column to the `plan_limits` table with non-null default value - that represents desired limit, such as: - - ```ruby - add_column(:plan_limits, :project_hooks, :integer, default: 100, null: false) - ``` - - NOTE: **Note:** - Plan limits entries set to `0` mean that limits are not - enabled. You should use this setting only in special and documented circumstances. - -1. (Optionally) Create the database migration that fine-tunes each level with - a desired limit using `create_or_update_plan_limit` migration helper, such as: - - ```ruby - class InsertProjectHooksPlanLimits < ActiveRecord::Migration[5.2] - include Gitlab::Database::MigrationHelpers - - DOWNTIME = false - - def up - create_or_update_plan_limit('project_hooks', 'default', 0) - create_or_update_plan_limit('project_hooks', 'free', 10) - create_or_update_plan_limit('project_hooks', 'bronze', 20) - create_or_update_plan_limit('project_hooks', 'silver', 30) - create_or_update_plan_limit('project_hooks', 'gold', 100) - end - - def down - create_or_update_plan_limit('project_hooks', 'default', 0) - create_or_update_plan_limit('project_hooks', 'free', 0) - create_or_update_plan_limit('project_hooks', 'bronze', 0) - create_or_update_plan_limit('project_hooks', 'silver', 0) - create_or_update_plan_limit('project_hooks', 'gold', 0) - end - end - ``` - -NOTE: **Note:** -Some plans exist only on GitLab.com. This will be no-op -for plans that do not exist. +In the `plan_limits` table, create a new column and insert the limit values. +It's recommended to create two separate migration script files. + +1. Add a new column to the `plan_limits` table with non-null default value that + represents desired limit, such as: + + ```ruby + add_column(:plan_limits, :project_hooks, :integer, default: 100, null: false) + ``` + + NOTE: **Note:** + Plan limits entries set to `0` mean that limits are not enabled. You should + use this setting only in special and documented circumstances. + +1. (Optionally) Create the database migration that fine-tunes each level with a + desired limit using `create_or_update_plan_limit` migration helper, such as: + + ```ruby + class InsertProjectHooksPlanLimits < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + create_or_update_plan_limit('project_hooks', 'default', 0) + create_or_update_plan_limit('project_hooks', 'free', 10) + create_or_update_plan_limit('project_hooks', 'bronze', 20) + create_or_update_plan_limit('project_hooks', 'silver', 30) + create_or_update_plan_limit('project_hooks', 'gold', 100) + end + + def down + create_or_update_plan_limit('project_hooks', 'default', 0) + create_or_update_plan_limit('project_hooks', 'free', 0) + create_or_update_plan_limit('project_hooks', 'bronze', 0) + create_or_update_plan_limit('project_hooks', 'silver', 0) + create_or_update_plan_limit('project_hooks', 'gold', 0) + end + end + ``` + + NOTE: **Note:** + Some plans exist only on GitLab.com. This will be a no-op for plans + that do not exist. ### Plan limits validation diff --git a/doc/development/documentation/feature_flags.md b/doc/development/documentation/feature_flags.md index 7d6a99d1623..392f478273e 100644 --- a/doc/development/documentation/feature_flags.md +++ b/doc/development/documentation/feature_flags.md @@ -66,7 +66,7 @@ be enabled for a single project, and is not ready for production use: > - It's not recommended for production use. > - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#anchor-to-section). **(CORE ONLY)** -CAUTION: **Caution:** +CAUTION: **Warning:** This feature might not be available to you. Check the **version history** note above for details. (...Regular content goes here...) @@ -125,7 +125,7 @@ use: > - It's recommended for production use. > - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#anchor-to-section). **(CORE ONLY)** -CAUTION: **Caution:** +CAUTION: **Warning:** This feature might not be available to you. Check the **version history** note above for details. (...Regular content goes here...) @@ -181,7 +181,7 @@ cannot be enabled for a single project, and is ready for production use: > - It's recommended for production use. > - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#anchor-to-section). **(CORE ONLY)** -CAUTION: **Caution:** +CAUTION: **Warning:** This feature might not be available to you. Check the **version history** note above for details. (...Regular content goes here...) @@ -254,7 +254,7 @@ be enabled by project, and is ready for production use: > - It's recommended for production use. > - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#anchor-to-section). **(CORE ONLY)** -CAUTION: **Caution:** +CAUTION: **Warning:** This feature might not be available to you. Check the **version history** note above for details. (...Regular content goes here...) diff --git a/doc/development/documentation/styleguide.md b/doc/development/documentation/styleguide.md index 81f6ef43372..b099d589b0f 100644 --- a/doc/development/documentation/styleguide.md +++ b/doc/development/documentation/styleguide.md @@ -874,9 +874,8 @@ For other punctuation rules, please refer to the someone in the Merge Request. - [Avoid using symbols and special characters](https://gitlab.com/gitlab-org/gitlab-docs/-/issues/84) in headers. Whenever possible, they should be plain and short text. -- Avoid adding things that show ephemeral statuses. For example, if a feature is - considered beta or experimental, put this information in a note, not in the - heading. +- When possible, avoid including words that might change in the future. Changing + a heading changes its anchor URL, which affects other linked pages. - When introducing a new document, be careful for the headings to be grammatically and syntactically correct. Mention an [assigned technical writer (TW)](https://about.gitlab.com/handbook/product/product-categories/) for review. diff --git a/doc/user/project/merge_requests/browser_performance_testing.md b/doc/user/project/merge_requests/browser_performance_testing.md index 10457e40e0b..6b16d907fb2 100644 --- a/doc/user/project/merge_requests/browser_performance_testing.md +++ b/doc/user/project/merge_requests/browser_performance_testing.md @@ -93,7 +93,7 @@ you can view the report directly in your browser. You can also customize the jobs with environment variables: - `SITESPEED_IMAGE`: Configure the Docker image to use for the job (default `sitespeedio/sitespeed.io`), but not the image version. -- `SITESPEED_VERSION`: Configure the version of the Docker image to use for the job (default `13.3.0`). +- `SITESPEED_VERSION`: Configure the version of the Docker image to use for the job (default `14.1.0`). - `SITESPEED_OPTIONS`: Configure any additional sitespeed.io options as required (default `nil`). Refer to the [sitespeed.io documentation](https://www.sitespeed.io/documentation/sitespeed.io/configuration/) for more details. For example, you can override the number of runs sitespeed.io @@ -196,13 +196,13 @@ performance: image: docker:git variables: URL: https://example.com - SITESPEED_VERSION: 13.3.0 + SITESPEED_VERSION: 14.1.0 SITESPEED_OPTIONS: '' services: - docker:stable-dind script: - mkdir gitlab-exporter - - wget -O ./gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/master/index.js + - wget -O ./gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/1.1.0/index.js - mkdir sitespeed-results - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:$SITESPEED_VERSION --plugins.add ./gitlab-exporter --outputFolder sitespeed-results $URL $SITESPEED_OPTIONS - mv sitespeed-results/data/performance.json performance.json @@ -226,7 +226,7 @@ performance: - docker:stable-dind script: - mkdir gitlab-exporter - - wget -O ./gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/master/index.js + - wget -O ./gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/1.1.0/index.js - mkdir sitespeed-results - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:6.3.1 --plugins.add ./gitlab-exporter --outputFolder sitespeed-results $URL - mv sitespeed-results/data/performance.json performance.json diff --git a/doc/user/project/merge_requests/img/browser_performance_testing.png b/doc/user/project/merge_requests/img/browser_performance_testing.png index 016abb89f7cb01dfc52611f1364cc38503f44425..a3d7022bcfc36c460bbf18c4d0d361b4a915eba3 100644 GIT binary patch literal 40417 zcmafZ1yEdF@-IY4aDs)P0Rn^&U9khPI2glA?f#y)B!OslBlo zo4c*UgBBW^kh{PGX=~# z+^znDWas=Zw;mj1|4YKo!3JRex9x|mLVs}uK3Tb&*=S2z*_zooKVXRP^KlCOL;wGf z{0H%$JAuw-P7?OE4;x)X)J>es9t>F8yZmeO--ZAC#(zM5{tqPYhYx>;{F~%oJB8T) z^89a+_-B~^!F`Cc2$m50zwem{R(+MX)x)!J{z*|o>i+&70I(#mY_|je2!dp{ z{<`Mo<^TXdUdcy;<*@Zf>Y`}^u>n~CP{K-AhP0@byQUr7%;fBxHvrq0F(d= z#=RO>mX=nlfPnS&)v&J3`|HijhXeP|C8uOv?M;HL+Sqf(!WtI=R>9|Ir;SrPu}KX< zA))h)VTK<0KHsaSJ1eJ(AHonuU_X{q-?ckg5AcD7f&uEq8$G2F7aKGA)ipq%W^V-G zb+|UbCM`TN+({Ggdt~-|??!Mj3_85?>sQa!d{s>Ccwv5i%b%OtOxw+0m8qpQ2Q!^^ zne~9ME92cOPRr}&pr)KKFbVWyW|Nif)#D*UU!0;LXAja6(Y<9FBURyY|xG$7$ zHGsM=E*t5ES8N=dG%lY2G#e~_gb`T%>fb~GAo-aVR-4WKYK5MIqXU&u>GiX{_4(n5 zN_!74x3FNhQUD+v2$+Wd&K+(f2%Y+s1guYuTRqykzq)D*1=MGImpB539s&f6+3M~C zXxfK3XygW1=R5=e==G-|E4HUJ-lg3pXTZ~@ttr0;Xcy%ikxlH{>loP$4C?W0PW37r zTVTO!jc*5gMhRNCmqlt6#CK?x%mRR6g)!c?#y~@Wg}0pz&?_n0({ecfCrji#7!(S0 zFcBGb%L-_9riy^awF; zuD%LgoV1xxCGOM8-;xcXlH`F{VRpDuU_vWJ>D@GfBvltb*?f+Yx2tKAbd_>%2pyH^ z;zGd>2K5qT-~7LsVGk;ejB#1g^w+J?{tbAM!CE0g;1CSYs7H=UkjP^W;b8kW+|ZB23`VXTgbk^)7C!M())Eecm6h^=OYcXf`E3TL9Eo*#Q-B?5^; zJwi&~YHtV#rXSK(hJouOhl5_UkXBK*=vkZ?h?x>0y6Vd5enMW9!8>BK;;nn%O{%I& z@;C}JHrmQ)AK?nXMAw({fPz!Bk|_lcveJ$*KJ15|x;kLj5YqX}BVoL) zlWLjvX3|=btX4q*FW8=_W2AT`txj*WW|sHS<9b~4&)U(X!Y2E?)GCvYTym4H?CjSW z!3*6=zu#}F)&#*%rpFf}O^(j;B!`EMeUHhOqaCO6Nvn#?d)upFMlOWuy$~eXTbq&UZbj(Aj`# zWC^I86Lv*w>E-m+tC?kqG<#|%LW_7VEAXCzDZXs_$tHKK3T5#0lXB&%Pz|ah`==K? zFVuQY%ECzZZI2`q05TdhFHO{OM9kj3JXSp^LJ-$jbJ#CUI?L}^@kP}iQ@pXl#UICi zQ^J|HXVs2u5$5=*_lyB=TB6DMM=9`Bo{7SMUJoNpd-Syw=jNVzybFF?m$zL2xWDKW4HYCPHWwn~`mS3@-*~Q_!i$=09*nc^4uoK81 z{JPCygE!DI>{r{)Hn1=K5L%Vhv&JF08vU)!lLGd1JB1eQZXYYx@j(6e!|OX9t4>Xi z8%blDZ!!rFa&RM4huZRbN#PwNZd@%m#)7N{&F3ElPG@)&7E2^L_>l4Y+?g$Q9K20- zK21|~{;B;1C?(uX4p@cDEQBTLBFYey8lnEaTFYnDsNKlLH_dp4cXDumedQDVG2R{* zcW*SO>uYFD;>N+0S$@5B`AH-uzxT>KR;uNrl(FFW*Of1%Lqb2KeH|g2@&3(GZ{P@u zU=CR2*cs!-9`3-i>nnUuMw$D(lc<55SG8g0N6B01&*A&R=@-^Qo+X0V3*A3%VVX}k zxws-OaRMAy>@v((n(DpW5yC$yWaQ*w3ZB$@=WQBmROu1ew!$CfUP)V0WBTZ+To6aX z7;UNRkU(x=MA3+ z))77)?m7*^Hbw`Lhzf9XZ{a%N8X3khWxJ@?Aj z@YNLGl_uA_%XJbSG+OprbbRU3oz~MM-6^^7C&ZgUe|0Ub2ok~mi~iG%eoj?qPE?OL zg$pkaD9AD-0N?2->~-blk-JavEYcRMhDL;GYDZyV(aa{UXOpAPee4r}dxjJ_`R*Fc z>4j-{qyU~Ecax+P+UBOatTUPNiQEf%oEbDfoq3nCVS+*moBponJXk5( zPztt6QEPz?sx_wzKEal|&OkGvL?>CwS7c`dW6ax!Qm(GLKFpOhRVKO#Qb8RM#tm_I z27k@Qjr^m84*(u>M1w6NtXbh5!n(>kru^P8TRkQhNb~Ubn&s^VmxV7!I#=^a@87=E%Z_VD|rpnq7`n;zt}dB`q$w&xOPK>SlVm@t3#AOxf4wZ&oHj8Coy`s>tC_<#h)|I74;r zPEiqHLF75HuKz*^%6Ze1)UWvAlNuC#QiO3})Zs{WxvW5H!iZnf{M*0v$835=n-e-k z_OF+V$2;FlVBwo9{RI1WdUMNKNAHJQ)Xb^v52fQ4%en_*kg#B;!dIN;uo%)@>Qh-n zNEal$gZm%}m#ZN6DH*~o)ciFi&i1a`1EcrjE5Bs+aICfQXtjR}qAJq~>MJlKeqXA2_u z-Z&cWe&jM}!}?)^RcUjFZS2FUtoELdcp)3j#Rk3DFLLx1<`?MuKAxG0KcdFQQ8I1- z1Z4YrQ`&Q+X=P?#Ybuacvnl!H5b5hN;1g{KKumA{(CgG9I@I+`*Cs z_{&D~#DV)Q0YYNt&&;!4>rxfE1wJW}7=bUoKGYhMHa$7XO(61Lb%aM5+Zi2{)8YN~ zDO}(@JMm|}zOE=Ge)(U+O=-H}?~FXZ@PVzEcg(&bxJhmB1KBOs%us^}Hi8 z^K)iZS>ifz9hPNB?4i-$__r_#T63EDV0S~!souKb5rRhQU8@u}x!&Lya8{GKVLNq< z;h_aFJH^MEmqg}~gWrE|Oezqf-|=Mgv%DC9`gKmv>3GaVtiyu7o#0ANo1z#t=5syx zv;Jf~xl5EFuEyH@d`rb{@R+6h-6xg1-o=?`Qjko?3y0T6F?3aCGYtMXPvgWsseY-+ z#RUf11!?q3l__&1y`2Ow&gSN2_QZbGj3lR0Dw1Ldqj{SUiIh|m5O_-YZJW4)IXnqp zhH6f&jMK`(TJXwZ$$5Wap{2jF+TZ{5{O0Gru-Exwr&IR1&u~*p3pcluG}1`vsE(Vf zXAx*(Z66Oxas7j66z#S`hh-H^u878|)0b)`O!QQMKr|~Y8}o?}_s*y^emB$CRqxkZ zA`-q;WFBOx&%DBh2A<5GMZ=PrRT!Jx{FuH9ZC)~2Uoza5Bq3&DpM(3sKJS9`zl%pn zFIDd--wOv_41b|%>s%fet{S4X<*6JdoxJ2Zx zZ)_-)gL6b37!aBm-TE3s$L_9@cz=WW~;qlUp)U&}>_n^?upuhhq#R?Y+#f8?Nh z-JWiA-o7XQH8@G5uWr&*X+W=q&6TXz*5HjfGN7$c$y9r{Vfe&LEPaKGtG|lfOid<` za!a%S(qSkwb%#=$@q`@um^C{_A&(7Pwzf*ML8$l)$@3mWcu#6-Xz=xetBq8ARXb^=T3j^gC z#vZp%{H#$tpZS)T;W59RTKOHlw8C=#3iUa!+bN#dRI8h1rP%KIhKLfhO@rs#qxTrq zho1;u>Yl6au5S+O8{kVwhaj3YD&#u%>O^0JG)dgv$_AczmJ)iX_;~0y4AFA4LS-7`+;G=qXMufG@ zjh2V+j*AAvKx+r1{cU=%`XEF5&-8$SmbZj7p5LJMw}@Nh)d2v+@3tw?2qN2){PMr3d)T#&$ZjAQg$4lqve>?fV z{P@qw|K-PD4*Ulf-~!@BxFLWV6A%RN+R0k7&VJOodb5 z;mRe*v-d8Df@Mw&!%z<=YuqX1MmXJ?u}`AX*3)& z+{Kg$@(e_DGu=|*m4Npc8V(6wst#QDreJCrW`{hRDSZFrM&qJJ;_TeIh7xMZNym#Z zozk6nIw;Qq8PiCIHt3bH=7(&)1Jhp}A=@~#{17`O$lJY&`Woj1&orN)6OCl`vbVc+ z=JjVCbe;l5t_^OF(Iixs)WDxWyw`(+kbV9pQ!%}Pn(E6{cDN=5#Z@k<+@qCx}Orap}_Sg5q(c_Z;d!?$}5FG!_D!|;PUkOl76D3D8tYjKd}56 z%YB0`E^Yw5buITu`<4e~vY}xul4| zF|==_3vVdEGjAC)Wyppm^L)m|MgQGO^}RE>0N+9HO8{asAaoa^ZPvwam zDd#IMX0CU4?Tw249o6h@Tg)tm182%HO}mlDo;`4Vo3lKv`?q)A!J2w!*?zXFS%Uj+ z$;U(Q7E^!l{M0Pnb(*)C?W8Y$-;mq_yE|(;NsbXrmhrWLziBNjZLfyO2<5)JZr68o zBzYPiv(G?%&5Y^xeRH}rs1H2%!RYSj-DIF{`Zu$ciV{Il2!%&>uYjGV>PF?5_{Yb? ziJFT!$=aSs?!G1UQypWfy6hYRL$fT5u=%xTj^%$MSeZNr%1*TuKlnLz>`_JEcfKL zU#*TRa|kP+)f;h&!cv#CrIIbcpv}YE3L$Z?vKFw$GE&z;E^!{QC^I(IP5WjUPuTODmfq!HJ%ki~@ZvyL~ z-{<@6H5M<6NR4Zwwe>(P?f~;rAi2_U2&W@5qy;JhwtC0iL!q@@(v%Krt5eG@%owC1b`%;r+;` ztM6*`@%TyTg&V)8^e0@DP};ia=ECahcvY45k&p>y9$iKDn_twuph8xCf)bmttS{Y8 zfnLW}A?n(gPm?ffS5Et9gIQ1V2)CkSh_<4LIyb>P9Kym{oNiQ=XX+Kfb6ifYzZg>E zbv5+$XS-l9SKfh_6rVBom|5uGB)A?*O!~Flo=LR8V+&G_QC@uA;hNzDbxN8F1#q|t zoKymBYqLC6#d;F9FYG);14VXpkPGY748MbI41O&(??V>5Gx{W3xlrOZ3xibRr1Mz` zXx)Y}tHgJR7MCL%XNWhCu39+*x+2bxJcZO(t0k_7&bLzU4Fl%EzmmP11|!zkFi+@8 zs&@nSAuvs8Q!~XFb!a2T=}nFbp=%EXDe8M}Me4?0U5nV#IM!vEYJRHaB=V4JBb=Z@ zD4EKU@jKqLz`#nwJfV=iso7YaXKEou893M{mV3aEHh$aLpWcODncux`CtGo^&(=L) z%H#065SyiZ;t^ShhZ4|1YtpvzhQqx=>+%5mgncm?yZ4~vZTv}tB(1O19E^=u`@5_6 z6pH~1G=}jN%x)bYW~K!7Y=^<3-G*KHvEiC1t}QQ}BABvk(gg#s#F`axFwu7j^oqCs zf-nTY?C`M5-X2>De}d|F!-?lOea};Q6D%PY0@5bP9e8ep3m3_SCzXVBwH#w=zH@)s zRKR=nWmChR=Er^k)IiI zs_n!g0oG`9yf~nP0@@!pFZPcFWcsJz35P!NKS01odwwA-3p^gY+KeL~+j+#MR{D@( ziy$}R0op$$RkLakEL>2aSZODPnK=Y3+%7OL{AyE{-|Aa3XV&KNvCh1w?$@q4-#2~A zl4@!Zr)QqhxzopC1gUB7igYFq1=6L9sLs57&=i?#=6$1RrZ5UL>}4`v8pHM=j<*fs z1GO-=z6}r|r%9Ir8s99L{dPJ)WD%m{&`x9%|k7Q-pTZ*yLdh})XWWK8+W zr2m+TZ3ymM1jx+Q@&exv(_mrUP3&m=_5}N)z%8p^WD4rkbPnA(&c5ca z-LtrSd5MkHO3wNNjMr+RvA78jT=r@!kqrU&r}S_iXt;=9s#|aLkQD>kyb4pb%k>7a_ z6R1CqB5mpBR>V(LSclPf;h%Nz7bwpL%IvJOWj%IhtM$D-^UOi5!(8j{;4#YvzEu_$ z_xZz9O?;@LC_mFsm%~inX9u4Xi3Qrp8^T9@UdT(q`TkDi<1&kB5rxzsJ8N4}_@O zLy^}`;b!o3Kk!Lx!jb#<^=ro^asLccbx6}N;G>=NE>B?(+9KZt9RIHh?{DLw%KQ6( z^??5^^+&8hd<`rtXgVIlC3+fsZ zGa#pu;^N+^6nFF+k$-UY)ipHM_}+v=nz9R$9A2&W zB8G=^fL1>3p88h;FjqQc5(C9_i9cfQqd45e2ncL+oCNU{*uc9uSi{RM&dv8yJ^ zrj25x(mI&|KUs9}HTgDs(i29*XVZ^Y7nY@n%vHK{iR88sX=hjNr^V3?`wFwZL!t%i zNtRM@o6c8!KuenuEi-c5_RXe z;H~cEc-?U29rK)0amR8EcVT(JQWw#z%Vo=^vU8H>#P#jPXK=32LGLZsL-Uz;rzo8IG6Ww4A z+vid0?^?6$mP*HV2g!6HCCoFAoWiaNk#0zgmQv3LpRy3m#ame(`^@jgDBM|BNx~7F z<~zHwFSHx08`!NWcTs86&9_(n74?r4fB=s^4w4*ID+=~OcU$qAeI#<+GiL8TdFa9L zoq!v%*lGj`#e$;Ouh+d5cuy}e1&6}?!lzeA~{=S6yo@k*PNgakInHRV?r79aJE=a%`V|A@Xrbwv>hBa zd^FW5;Cb0Lo7GF2tzt0h8qJa=`p#tM6|{2RQ$KxzCPOYQuU(3BP_tmTA@z<0E>gZ$ z{ly)5fHJ(pq^jV13zb1}Lg_i1n|!q0=73Ra-0gT?w&pz0TZ-nwX>ORHx7FcM*NnIS^Ez|OI~;4W|rV{g`&lgK#&BCZoHAI4w7P0qe> z;I%g)&$O_`=f}PI>sKdw{T?JZs^u~zqH&l_xvDMCmNMgm*$1xZ@*A_g5};^}aNQy| zs}b~5^X-u2VM(3nfG~-#T{@aOJOO0?5pbE)aM)nO#(8VQ@-i2F83jI`EooU7W>U5| z@|8%@ov}g_+=jTu(m#fI{8#q+@6@)&{eRDG5BU!!07ZYzJ2L=&`Ovt9Vf;OzJkbA> zZT$6)1RVa<-)p=?`iaeL*X(z>C8cILi#CP@$@?{>FFZa>I1(UE=TgB$x#$4fzVEG7 z!lWh>eSt!ANm`q2IJH7X=V&lHXxl};DgEc(vtMi6FSu@U_Y9DIx-+t*$O-BtB=#<~ zKVl#9Q`#6&lE8ST+#Htkp}}3bq%0_4y{1x-!+=?ehcbjIc*4!C95KGM-V~OY)+W@# z^21$IfVXF&)2#x5-j;-WUnB3MyRU2jXPOMMv#j^}T%xkjzS_`VI?I)D;EGIMjB}xd zj2&!F2IVg_G|Wu;BXp0N|0G0p@yJJHPagZJ8>*ss*dgn39aP_dZu3?Y{R@~7;Hm8g zkb=_NLUQTEE#@Y-Otm8EtOpZyuA~spl@1C29dmm=_z|F;%aVBBl*&V)e^D$n~&vk5+$ewxe8P2tS}uvsjPr%4d z*D4lXKUK_xB^&xh>f^d+79g&ZD_<66k!APqOmqeXJ%xPW(zx}|a@KO(@fm+joX%UI z;td^qJC{V7g-|NI^Z7hfTdlvbiY+nbQa-Pt6=IBluQa8C5-JxX1SV<7o3r*emEf%M zkNl_n5i=VrjM7sj23H_a@)!FUI(Lm1Z*r8&+y=G;h+C}XTeLjjwPVPdDR5iTD-e50 zP}~d(*7~CZxjHd!1x)_SP|$E!%EbAubvtr*V^A@dlJo8}v}~|sSBMj^2+Yv=&@HWK zG9NeBiip=r|FdxJnL?GJ3%%8JW!qN}#c$}1u*ScdX#Ssn0lYr2j;wl7T`zjAh-9Ty z`7~#v1V%LXXT{LbKo-tf{gCrs)3>&8yxQXZo6limnB1Cg`K$bncd7*Dc$g&YhmoJ; zGj%krpultZw05do?s&zUE&2K3Z1sfIx~r|l5pA=#g?AZk<&|C>msJyN>A{$0(e%D; z{xAD`qt}ZR#UH-)Gx{=D(?iA)2s=wnW}+{g34cI^YrW-u2vXJ5j@1Zt#Dkx+qdx)@K2l`;A*S9%p7cikyREDxc2IykZGpIpWdX1> zIT~M)>Qho(!n=T;lnU&HoaigX zycK>?bMLgLvqU94J6ks?v=$&7G2)39X#qWO(5C6J$j(8bk==vtW8r8I?W#5{^nW$Q z{HuH>;A7Wpljm0wQ#4B~C z(21et(<&s~>7)I5=6~wVg#kr>DC7Rqw)?B@dw~5bb^d2s=6+cIvmN&_VFfHdcpz{p@xC>HlmjAh_OUVZ?)$ zm&+W~X7)aBI}6TcQRF;9t%cX3AC2+Ikhsxzq+@>yNtvs3x)R^byyU5zJJ%~k9^GYb2xt8C66FVd*H%ybR{RCbWl{Uf^HY$I(d8S>95D@wHFR2H0r(9_3qzJozQdd zf*(_3%Z_Yo&KbiTuIkN_iQU~cXW=5Yk#q#Q3HrxE4AeNlh1mILIQ{UOkCq4MruV>dvncy2HGRS~w+vlIPM8Rb(-Sx211uMibu`9TDhzWE&ho z4u_}k1!jdqGEbpMvC;Z;o)hLt==;2z)q}pw#a0=-QFD>AmwWFXPnLn`5Bj~|(YTvQ zm)m?UO}M-D__X;Q@@n)Xb&AJ`)ZN5MH&pXg^Q?95LAE-?0w~Jl5+1RRFCkPdqa;B( zgm0Z>7Jn_W!pqRc1brU_wL=NsXPPGyz(3add|+o}Q$PPMXn>$ybC{U^TABwcUb?L{{3AT7hH_?=+rQjd8HsIh#p8D^xUp z)9VuS&SgCHn42qZ3A)27ko8+8i6-|ZF$&=rxCiRB#TR76zDMf$`BPG<=yD`~Weg8G zEwp5HFO1a02jv&J&sLpCzC}Dg(bvlxT6HXpPoPX&A@x zW3b)-HqbkHT;aEz=vB%QGlX+&+b`Jcg+D4Hf+C}7R7qiFReh?>f2bv3tuiX+oSs-6 zKa}B=5ZIqyT(;0=uDmy1x{o=?!q<%eCy#Q*TbMyu#jZD91fdlg6z}B#H(*Q*YTMqU$dE zX^2;acm(kua>%jqW9Kipz5HEE9$(B)V~(`p({aFF{itc_%jRg0T2N^9intW}kgC~r z1zE)uC5Pmo@jlWRl+Cl7Iy!UOpRxT!1ZE^#OPa)p{WvlS;arFC<#uFoHr+;{R_?Oe z6rfsZgF-2=;e)taKNXc2FY7*7G=&f!0KnHFG~Y71ERka|CaeL!PwAvmY69U3ZxsXW@)f z@KYk_P7bH9#696U`_tBa7AIFO!SCL?o*zdvodWL_-I6?6>~Fpy%x4^-5Ij6Tid=dg zXqnB}x5+e+U>#ygiN^N&S==pV);gpMYw)@IpP9PUK4eg-(na~w$YeA^Xu~)oa2!$$ zroJmafz4pc4gB-=M&Nr02#@;|){BgSeRFC`7LiZLh4h;f!EZ1hN2Veel(~!ckDsg) zD|xS0!8A?!g|<=Y)pbs|289_VgQbbc0VA4*!X4+zpGxVc?zYz%{l~K=o>UQ4Th~~s zo4D&?GY3c9U#C(^7}|zIy;v=R66S$%bRKz^j1PB!_z1>v^=W?))E@xQAvh6GbNM9l zd$zQnM`j|7GAQ-2ANI4FKkkL|$y;@mnm(2Va8le8{xDk=k;6uSMdPb5&`YYgJdetw z9LnolkWF1IC!vz;uG%RMoeF;AP|`UVZPOo) z&4VzI?u^C(S4Ah9$pt^+(d^=ujNyHSK_YV_UV3-asw6-EWYDhwoAUlu zH42GSo`n^r)q$t|8N5GoS44WT`bzqE|Ma+%gw{M-i~=J*m8_$%*t|##JhRwzmDzKj zT9D(iJbSUZ85J_D#63~J0)mcAh6~JIf(F2^?wrWq<(ViQd@bceT@z-dv#yX+6nA|4 zv%9f73}w%Q3d}NZ)CtWIy~p%fyomO;GoO56>uRH?+m1aqg+GA*u6{Inaq(Dq1LtG+ zH>0|ZP}-Ai}t5x8V9B%XaPV>Z33e!{VYw zp*v7PXX=2LFwRi>z%rcy9-na^ z+S5mc{-M)A$yO6FNln{GmiRB>;+WPG~58k@6FRIU|d&7eC0;EO1PRP(a{KHrMSfKtfrDdkXOOu^D z|w7WUw%Qj*Jm*>xmpz$ z5;IpK_fBi8b~!YB1VF>MC@-1>-v;rw6cDeNs%tOiJ$+Y~ya_4^$jrj7@5El8DZ~%-E`18T~ ze-r-)%s+sksSii^FPwiP{~hKZco%+$0=|`9_8oOqDDN6T7c%ql7J|_imz&q?HfVfla^qYsD-ziupSu@V0~lJd$;`4MvkUz z{@!uG^zl-h&sLyCc2WFMT2G#D!V2zq1S);(ah<1QZx3eh;AEnQSaPtsi{;ArXOSj_ zFJ`#`cM8{1?NaD0eLMFFEZCJXDP(nGQquYE`r3-w-9c-hIr!@;dF(tW;T^w`875+> zc&Yvkq8S$JV{30mR2Lh1faxC*)Xl~7O^xZe9X~;)|55(5d}}MAJ^ZzI z%U!9qUKGpI6JgOR!`{~{A2ZYYc6O?qn#pV$y(Y<{hvI#`eVWV5D*a5No&f?Xi6+xr zMJ@nMa+rU_7o5^S%c{DkZkkA&HQ=YX7qp<4zW2*)mAB89VwhaLy?Zhfj*gC2P8kMN zuwFzO=9q)OG%z8#@Wa+?yPh+R=}c2yudNLW#D?URJ*wV;((dI3T|D{TkB!Y`D(nJ6 z=VR7$Tb>%R9q}-lGQeR%V*vp?|7})C_sG z*doK!s#~MTcCdK*YP?QXIdkDkhu}A~GV_xR+xSZDTc?>-UjfZu<`*xUq6Rp_rqe|% zQxhYeF3tmZg0{%|7>LYv>W98jn|6~ik6V2jrR^OGeGZMF5ps7pXY(ainRj0UYDqaZPQsv${9~u(sSDSsM`%ZxCq;OC|NJ3?Nl7}yv zAyC(6Nk0k@&D1o9yNShfv@ny+u%M@CABjvpX%0o4pq20@$oHeCwxLj@757eY#Xo zoqnF&9YvLE+Wq-idFWBYmra^Rt4M01E@Z{r&o5y%Qf~B;bIKk$>k}!*>lHkl_V5YU zPa=Ihxg|6Lyz)wg3c>aXk!mbLSMT{BpTyX4y`}LnvaHpuc+$ii`X$ z!VNFYTdY5Ap$t)>PbrpHJrYYG_vZj{6EG9mLiGDcF|(u4C}q_!Ufq@4yD2_x_Y<_- zK$)4jQHtPY0tc3!A2&zxDb17Scx0~lUvtaZi!VJbQ|Sz$e_0)_w)Z(R!oFjcs6vV# zc&F$#sATH$NE@%eq$G${G$BL?$F`H7e_d0da#UKbn|0|k7Xy-xqi_Ekt4-%;ZJ^;o z40FZ+0D5kEUE@F^fR1S$5s0UYRm-jaS%t-kiZaapQG{alpr;q zu^$~L_W?CGMo77wli8vJMuFHvW&c+7#iu*`OEphOX!tgh@&a359NKO<+LV-#Rt}i2 zqJ$dxK1M1B4Czl|{>Z%{zolB`+oQ(U3|5Ysh#pC!)4maWBC%>%}E!dIY|w zYu&4>n8fqE1iSVXXAmWP`W7#`{3Y(!FODWjsAPZ?_p^1h z33pV0Y^o&~gZv9oyem<{d#l)$Y z-Ha)r9mb$4f+Ox4T=%(Eq20SQI=VO|BA&C@@M3~96cOWdZpNQt~s?GcUfUFSDS{mCj zdu;O&{K*p?FIxDepjP59&q1Z_v~Xnmo#$>KZ^9@b*atUdeoG3f;aze`1vP4L`#S9b z3SBP?%1MRE{`I)ikVkQNve>gKz8KB!TIw7eT#8w`-?~ff~FU&b5WUvfZi-nNsN*_26IUdvR{=l z5INB7@65I=#PiWRC}{E>avO%m!-mdRi$X8ob_dDo#uP2d zY+mnjz!)jOsBtg(a)F82sjkxWC#ON=#Cv31XRA4Izf(F?^5_CSZjsSdFmeB4B|KUv z0Wqj{9Q={Oh4_qHkFa5u&!q4At8U^+>@e!?>;e^8mc8h@o+rSu8f5fuFc&`j z5gBDc!G!wE*4FM$^P{rN!`QpbsII8Ewb%qjB^4Zq&*P2LQeA>c&7C(XTS-p%9YpTA zB8#{^A7u-Nz)f#9jHJKj;@-=G6fzS-p0TpTIYnYp{=7cy{j>6}O842h8wPxL10^=Tm% zqUhUcH+p<4@yRY>JDGp4Ra5AIr(X5T;Mw|)8~@;|oZ7jpP6=Rc!2a5YM=4yzk$A`a z5f)La)htvmpgh&GMCeSq8pMZT-)W18Kg!zL$LTP0FSFJLZ?~WCVKH7e8!N zyGX#KxGb(whQucN#sznuAwVvn=WP3dSD#v}YU=C4!g{y|_eFLk5?Z+m>sxrX3y*Ba zPjfaiQaRloesl2qg3Ol8IQj9~ZGRxudDH74waINWPt&Kb1EH^mv+Ws?18#mQwoQJ7 zD!JT#<u{#1;XdoNU%fG4R3 zpmKi{i~9Q6L9zMSY}EP_rwRbcKI*dL^<*hc{0_`QZ*H#*&DBuof3xLrXZ`Lk|3~5$H&slqk}@Jzqu%ca z-U(jwPd$}Fq-LYlr(lW#p&PC&{)p1zcLGWUMrk-r6%(kAS&xzs{4sT)1Y;d(-Re|D0qoUlaQ3uXgF+XDEn-nV{a z;?M$>o;WzgAPQCb5ysVTD@_{f3Yl$bb+gRv%gP$f`BU_>B9>hOvdUEU;j#G{QE~FGt6hcY_f<+`o~6O&7Gg& z1r>bxw7=(L7v>ij3PKiqT^OhsF)EDJRYJB-_EnZ#vVZpKVpN>ifBiL*MiG)PrI@ex zCAz!M-bNXBX-UYn?8DijE7VNrMa&AZSlzQ`Jd`-^*xRO_%|)%8jVh-Kq^5mTwy!?( zkYDMX%4Zf|@_rnY>k^TI!^{E{8A6{x;NA*zr%NgLWhgr#CVOh{zxGmZAG-5=60@~% zta&DGkjla17j`th<^ywqL=hDx@hkuw{MD5k#BdkzYxrS|H!>|4E)YJS{mt{nFxRKVUWx7)?2?a zbS1qYV@I59;8>WGOKsmL<%?ej)>cd69&taz$ z^=Ym6{k;Q~PaAEutI5kKcI~tc>75biTX=D&mcT{80Mk8n0N)?VS1A_MZwax39T~rk z6EGtQjqMRm;R#6Jn}yw;bO)KA(;36#!^qBtw`3X^OUO{^5(d2AALHJ)%9w6azt)l? zpZ_2o7cEtG0`#&|SZ8RYw*UQ}qDV?GC(1-WAKb{-xhR?B8w9f7PT7Md?U_i8+>*n_ zH?}Sza!)#)_{!PYv&dAy8BmjS3fmfwsDHj%c|p*To_6% zAGBDLjsCcDg=m}J_wAf}3_+1d_`OI_5_#KsuTRd(Kx)F_V6YaWCiQ8Q`Yzn<(KzO< z(Le_O4OPiag8Gi&KAFKtSA!wt&Zdt1w!Ol7#Nlh*Fzl%+mfFa=B$2AOs70!$i8Naf z4=PG*&@B4dhGT}sDO2LYN6e=oZ$uJp-{LMDpW@6eV^-d?YQ-a`Vnjo(3*9^v1|MF$ zZ)kjuRI+MCryZd&bt&JGDfZlAOLX_s_aI|+QMOOovccJzg-0f_G$IoLB;MbMcClz7f%-E zCm{0dtE(oqJ=<5n>gl8d^Lxtbb%RL7&o0aOHTQ(~gA-JnD(LswUE21hSF9>?J-)YU z^y9bN>RANDG+<2x^ox=`kGkKcRIQIiPgif+WF7YdcdGnzO>@2Z;6IjA<&_nmYs?|G ze=Yxia&GVxNKjuiY`6ejHNFdlQ|PFa#ULa7;$mBdH)&ce!-If1Y6t6!>{I`yHv$8J z)=B~J4_M0mg@!h9tT+0zISbEEKIomnKI&+~PKFtb)Q{l*RGnW>QluPc09->^rrq=K2kl;!4DQTnvvVENGEw{8C(;ExN z{UpfB=m|vV-j1uf%G<^s3N=)t5sfsVpCiJjk<8+pX((AujIQ4@_O|n-MoW-0LqlVv-6;2tFX&&lcK% z=mVjGy@?x$!`EjXneU+<3Jk}ARhC_jFV~g#gqGwLbU$l0JO@ga4j^9y856KYjJWpi zeKb5}_aKfrq~ex#HPSLvC}9bg)0B*E8zuJ!T#nfh=4iqs$dm-R-vu-n^KtXVD?1GU#DBfj5fwvTAz0igCo;rb#OO zw7boQ;!~)vD}g2I6`~l;C7~Q~BU>sxK@t*0 z;C!^Sk~)lhQoRa5RO#G>pVW_O_>&sIC<{rtLK+tjii(=Yff59*a()sPKr@Mn63e*H z)mVWvHR`{S%+qVTKHN4}KV{(=Ggx!OhGaFcN81h;g=`78-nEx;Df3Bq#xFq1Ynl<$a&?6&N{K!-(JdKEm)^8A-)cj9migBv~YJ z@FmPxJRaPFaE`{fXKWgn&-^pKv4Nqj=}CwNf)L+3nkZDT14|0iqe2@Bdz6u*WjUAq z++8*z4zE#~t@7&^hjJ5kCcQ8-V+;|l(9k`pQExMa>H(Vg=RWPrFsjd3RJSvDl2MHC z7@218Q;WQH z)rTQLCi^NaQi&PF0vmvVrYP*uA4H0UN3Ia4Y(D3gw-NRpRyGJ*A`dgK%u=yHO0iJw zBz4DA@HW%wti|c<*u7)0J!0~8EXM8hXQrp?AE&c%_erMw(OC0d?7(b$Jv`670(P|V zdgCqZ>^S5v5%$_EFs+lD;a0H*k0+UHgC2NEj*E;fyOtl z7v{nXIV6MI`($3Cdfr!#k4TA@J1JsqF`KIeg8jAmjb(R^FOYelaDi6x2Zz(a%=nOu zHV1Rs3w&+h#p{!1`b%TBWc>XjhQe9bk7rjQ^l@&TWV5j_sJu-!?p!6t=L#1bq6+>x zn&D$Mc-RrA?DR>q^t!&I>!*#s*Mg>gsS+js$Iy!uA-`yVCWHA;W6Mf1R)Vq(}%a9HmC3T9`*grm{ZrTiwhc!=1P%B}3`2*znB zHknT^NEC}h z$w=5IIMiwjZ&>gVs3nof6LLH7NeYt}YplBX+N0DPg2Rz7OEur*0xSKfZ@Ri-;GxW@ zKB`|ZogIgkNIyp?bcttgt5KqFC9cZtY--T*i5u6bg&i}hMkmB@*QfXSH6j9O$Ks}1_f+~%@5?}(L_5h*kn14(CWj-Kww%&FnW23}6)y!$M}@#(z?2X+%9 z!lL&KTS&)}MA++k9I2$AK-XrXvE@}xmo3imuglZ{aX53{j4(s=bBn=>6X!)bV3o=q zi3U3D{_@5qiCD;$&)QxTL=ljRG-L?C!>Gj{8!%@^8&eg`t=KKfQqlmV_88h^tQP&q z&G;=aAw^M*KNf6Ef4Wp#n$7t<=WL#mbP*xl)Czzi;{Q*j~PUl`s}FLiK7UyLwB!f?7!B$k%VSY_vkQ;=$9 zB>N>$Qji`U^rydkquu=c=zHZN7b9PzWq&ZOUY4b)*k8|EW)1Y(lXKzKp&gX4NgMAw zXKk(+gz0+AR6kbCZH7WJw%c0+NJ09b8MSN3o))^R72v{c(;^$WJFNi#*m-&HX}sr> zs_Ru?NIA;SE;J?c-JqS}$h6B>NB#?FWIBUSXP9F1;FS8gNkc0!DJNZC%-PIj0glpHn5T9aKCzv?s=!olGTDEOQ zrlN8=zuqfNzhjF%0P1()D@w|kLIoc{y%2OW7wGBkYFDFUK==&9wLeoqqcZ3i&32!# z!+V%vnoKG!ZUp7n05T(y2?T8sf=`TGJbc|Rx>O&zTbstJBW+F!i)&!uKr$veHzBJ% z!47RK4!+V89Zc54or8cVM$5amW~SNLrGt(HJL<{}&AT;3e+d7*U3_s+>oao4|MKrXg_mazg`mOth0zgRw}%Ljyco>y7>n?_+}o zqbJRG!8~?)qOw6pvPG$@ciwtzf9w1CkGa4Ta{yBBQSht~dUJ{QiGB;92pPgUiiSt7 z!FXb|nv&6P(9+!Hb%vy@?c`-st@F5}-eS}a_3*~U==8}nm|5Ps%v5bw zk$soF{e5r`g?DAYWAP_2Hq&p(GH=h6svGx{p8vs;jM(29*G{*O zOZqMBxJXQURY|#3`Rw$(f%pKtgvx!)d<_D_om>WRk)(S26E>&De}BF_7r*^ zR31aFqESAt{xvkF{@S~f@kOb1IRH1uThu@Bb1yTTs=e*Bsq_P5;>iL_RrEJ6WN+f zQK5~L!<%9@^~S~UWigK^_#fY8$aA5-BPCp0?JtZOroql2u8$R51$u zOk7%3Hf;GTn}EsQx9De%cj(c#I<#sZBr0eF~ywFaA>WGwjUOZ9?4 z1_xB<21XU!_o@D>=9~s2xi;KLN7u_KvyM4M3Q~&}I?i8Ki_RFfTXvt`-RxgH!oAYs zF>d`!X(}u0ArOwz_bHM4nnesIpK+nyul}ehVTy9Gm8`ZuY?GSM*?|f)Oo(Lv21*LO zX6sd>l5grJsNiu4CqHMS49_egg<_X9Pb2FO2G+ZFIy(42#VRB*9P9vfOLRbLr{+8X z7M)>Bn02y1=-o@iB~hO~N%Ef5s_!Vr*QI@z*Y5itxzPTASnJavy*waw1Gyh^O6F~| z{SvkuYP2d1z>}$w-&taduS_UBY;c0xRlY<^iA<{MrUDC28!2F_6;c;j=3?nhto-x3<(03+$I9;6mPNH)4*k_yvSFgQB z$;*&}a{n0pR!_KjnNnJiZbH5ulL^pVb#bh_OG=Jv@77?cu8Fv+he1Ldn+lcadrm?i z0JPj4Y};Xs1CV*5-hI%Y+6aY_#o}kzD}MFoh;l`{RNgmv#Cxr@lsSH?Ieny1Md-^A zQHd=+CDK?>E78=AnD-Zxuiup}xxEj^P8pg<0?K8@Sy+l1KLI%wy{BdY|M&6zuq*=i z-psg|On$JVt&w1 z5~04|{5m6oHVW~LQKPj~xKbL~4a8|?EA*^;qop&=WvcVWZoZ&;|GcPNDhqYGNp(wK zj6>Ci!T06n%!0_$M|1c)FA%PX1V@Hx35bH!^)|F4ZQIMalK+Tbb^_5r?M^0x)D-51 z4ovDmj~b==iOj(!O~EIOmnfjO8f~qJ!gE6No#|?X^(3k;Vv&n@!=InMaTMtVl)QS* z4u%!nr;2ws2=dPYhQ}tx6(^B^o6&d0*JzO=iFaaHfoWR2_h4rq^P+?L_+`fuwE}VdhAFsAbin62i$h2CInWpXjOvU-1lVcz=SA1o@NJ;?tgN8HYD;$qqLA z(!p<-s1t>pLgn(lNg;*n=ZVijNciG+kJK7$P_nwxK?B{iK3VeIl|08*k;_|mEvwP} zX%-vvjH1dLQ@(K2kOKYkqf|X_6AU%HKVysKZu3`^J=n|T^oJW?t2aiX_AY6Kb~BZm zuKnu){i?G#`Ug5IiElL-Ey~L1rRsImEU|-}9d0dMM3n20$LZ3_T|4!1X!VhrO(dqs zfh>H0cP4XSmDMx6ae&A)0@rfcqv@F<-g_So=}OR*Qw8#2U`<#v4#FUv+HUn^qJ3e! zc}r5?9vsR&& zjP}_q-nDqI_IFMiIHVHC2a0hIT2=#^_F(;Kar6ZHrUtH{uh#YQ@#k;!#|0t%Idy{0 zy!rU%bN}S_J2L+Hee>t-gI5Y_U=P`X#88!fy2S7Ttb&bCbIet5|9eYP$~+C$A{QiF zNs(|Z|M!A{uo#LNd3kExNabqU=u&wlmcp}icI1V2bi1qHy1-{gI4tZ%|J|0hoGE=8a+Rnq6ltrA8xt+}5@I}J;{w}t>QKf!* zsI^q8!>>aOxk=7tn^Fl?R(I4A9izOcHqVwo!8iAi^Hr-gK?2prd;eDLt!g}QmqBG> z@ULn0Al5}T!cca$uIy|F7PL}iUb`>$xbpcE*jznG z>TzpTxK?|J6R2$TPX0ri(Kd+3LHn|EryRABz@*f(MIS&%GxBKhUTkPGVh|D6Ypn|CbqtbM&o7>VN~o ziL)nWuo5+|QU*?sS4QOa4PU`}lIF5!f&D<4I4? z6+_X^sXuDhRKHu~3>K@?rF$n1-5t%9t6!C6EKXLzdbDm2S#YhP6I5aFT{a^qQeYTu z@lHBSq^aKKJT8w&n@TkBROSts@px)@(J>)zFB#X>ec(M?wtdD?2OHu`Z?)nNvf?l? z(}mH79|x2z3*8p2Z4=EZ#aLyjDlk4oMy0%UqGW*|lF@Nm*WQfcLiEPXv$F8XywK6# z)>=BaE+B-wi`2vzFK%)7eNpt%p#F|xanY(;)xTYPKHK(ARaAr6!#C>cAdb%q(QUKR zRtJIdBDy?$;4!5Y?amUfrpM9VMiq4mV%+X0x5c2Zu}h7c9BCD&s+=Zvr%#Mp@LQSx zOZWX~8cxTLt>#7^&B0m*c4FKUWv(BcZYn0`>-fL4Ja8rv*7K^aK61VJ<=p@c)7@7G z=9GRHw7t13SK>7iC3kaL99d1c-Gr{;{lNwaL*$rEUck_>gn>D}9rmM)YXKqFOC!>< zzyG2Kchk&?o8lU=8#kTB^#41mfAgYZd7IoZZG9?-4hy zyJH@2-kXwTTb(BQWcQrwarM~jmXC0ko%x*3548vFN(6fH#!UJf?-X-voV za@fIotl2P{s>48Hl`yrNfpaSFL*WU`@v_Q8b@>zHw$VLog0aS5?i%&-i;w;DR-Ty4 z9_uvAz>e6|YrPG1>DdQ$xsnGeX59WtZhUFmH%rTihehy6sMFY`#O`Ky}JuKt?fOrlnx_>KW0B9K9xNeh`#Wx&O? z4caEzrrx9;o=2Rce_IUTGs&gvZ3i@&taClIo|o)xhg@?HewEsGW_iP5;*(lC^vozK zo_J+H_i!>|r8t+N;99*lO_cBZ*OWTweEIMk|Xvn*Vg| zH|hA(F4aQ?<|>s{sZSweD9=bHUvY3Y;&#|8r4p^t28wp>FIS5!>hWS|a$GuR z5KnBUVjIOXP)(w7Q**ic#&5EKz zsNevfX~O46GkVW|^pRk$uH`@YcJv{_O5pUBB;hFc;7_e%m&gR}=qn&$xGbh@W%mo{ z{LEZax4w{FO;+xMQ$VL2WAs(^4~L}nwCgbD6dOZ<3PsWdDuR2fct7GBo2-#jbPdkb70$}gUk)eiIt8A}JhZE@WL-Xwj^?w#plRyL>cD9$G zC;yi#hHfYZTpVGh8R*#tcJKu}ALqwM`5q?~A`B5MiON2_N;(8Tj#8!GedI}U{jMHe zSx=&gcSBcMz!%oAg=T@Um|+3BKMitCf$LrqAhF#|WvO0hMOoo1E=<;PGz=-HP~q$? zkXikzZ)|`UTzOss(TO^;sjs-Zic2Ry)Ba=e28p1wE7d1~qryBut#Islp4itRN4EfU zJaRIR#}EdrYIK`@bnYIDIs2`e;CdxUFK6oktZl-%euB)D;*K^qEs1tWxt6^2=u^3LSA`$QrG5qc>!;EzJy_asbQZe6+ z^4JIv+z^<1c>a|#%gLY|e|BtOA_Wk3Hgn9V`Yke>M=jMy^X<)Hb_u&uN}jF*5L4Nd za4`R*^>Jg$7#%sz%fp++1BlJy8XwSz`mX zK#-#P8+hqYRWUibi|duF%hhxxrA))Vw*n2Us-Al&H@mKpAQ!JXn6a0(l2jkblGNEM z(kRVYq&Mnk=|VR)>pyV|*Yyhljw(D=JS+Q{6tN$i)G87`9Eu_+D=@K3JQlmnVn;Qb zij3#$)_sJqY#|J}DCyZtdaxqTQC`Ti|2$2uanm_jyAC0yxkB`t^?~7Gc|+xjYoTf^ zH-*`_+vI>0f zIyI_J!IG3nr9EQR5i13zRVu*0u?GiQC^B8+-NmlmQQ{yc$78U>f+m!7Qg2!gRb*u+ z0?(!-Q9{M>>CWZ-o8OuW`@p4I1PU2E!|zbONouHTwIJ>!t+oz|K59T>$Q+cvP_Gya=Mc@2v7BjmUP@UhJAAO)(Sa#f4HkA427NSZVZ2uv7~)MstxPv zLP4CZm`*+`eO&(jFnE$R#YB6R^9>$(iB|PuGYJQPU zicDxe&3*Giu74XY{F8QA(pCpj>MQfvbM9u~@mkL*R)7Q_74=(yri~sNdHHy7sObjk zUyO z^d3HPXy!3`3O&_RU|!SzhrArRsaGw=#4*i9b?eM3oZzd$ECMSIhb%LD%kch&w>cFx z%FvTo#lF5D149k6t-Yn`23>07^Qv<#keMEn(^07X&!Q{lKQtkd7+L2$!GhR#b98N~ z>Z>p!BKTR$i11Satw&^Ghg@L)g+@__5HOafJBzcJzfQ|<=c35O1#aooAJL6CisS@; zn~lt)66rXfKG1g|NWT4W2*=qonC3$!mF}rR#WW`*mbOSUu301Zxde|$My%r(;sFU4 z7!KpA1Y*6amwM5%Lso?H)}Z9j!|SkE(!Q}8(17`^_D7&(!3K!&O;WVgV?f`%<2PsA z&^w4}vW#i=N*o(Z-(Yj|kKVlbEK9=+Mi)2;Y|K$Wf7;f+*8+gd4o=!W%?DY)Va%3u zgDL)!QRNFW0AVpNEtkhNYZNvt%2_bn&E#j(|O2s+S?)l7S1H9=uy6k9Xvq+V3 ze^?wkVh^$ZiPG&#MbxC~C&;)0#w&K3=Bd11=dFKDWeCC?x|z1NY+NE-&ce?CHBp{g z1Oq*e^I3*xrO?IVY5~UmOul8ut=13i>ZkrnXIlrCdc^2wqFe}@O#|DOTgOGRg0m!J z>2?X9X2&WiX@ol}#Kjt5`W)jJA_$Hjh-AF;OCY|BZEb%iAm4P?s(r~HYi-m#lbUtt zVGSzb{N+cQPzNtnwsDSyAea>PM)EOGi;P39zp5KU=zZ`n%3n#?*pxC#d1W~fq!@D% zg%M>*Vm32PYSDBzRO~iXmvMIj*FT_dK|$vrS1-~Qz>$KRjl`=7Muoc2h08vto?jBi=?#iFL z?9M`BV`%j<7K5{m3&&T>+bb5O+m0EV#xKMcX^wI1=1Hd9QX@%vqn|}x_mhe@qYEz7VRbgM>c~vyrjeZ zSde$@jFSR@3j;~c(FGwq`0hra9EzTtb>9-)z4Jr5gF9BkN2fJe(=9aljWIvew!&&g zn-!&4#bKYoS(17Hw~L_2v|w+gM@?ZE0Gc@et#m1pQSlM1MqPK`FE2wU+^!CPk%)KW z$Y0AGqS{$M^5gTHrbmu4U3K6So$G5JuCMJZeR2o-q3ZgKfhDp+6?!+$5+Sm`CdIMO zqs+LyzeqV@G@u4gL7qeXE>GJT^UKaZL96U@M$c7%5Z?*EJ zX4ZYW8STk5`7JJd_=x1XcOP9KDtxFVEd0;ce;NOM>i?eqTk^k_f_iCg*H!U|eRh-G zJ~s_UHr*{rwuadW69OJOU$xM4J_Zf6=oBIuHg{!sQ7js?sR4^o^X!6oHGGc`fb$~} ziMFz_YpUx?yzfA%GsJ?(CF*lyv;4)yn*1Ji3F`yx(jD}gve)2OSs+^3v)(`xK;;2f zMu1)ERdfgajR|bUmKz=Q$r@$n$T;es5PruW_Mcm?-j}gSzyH1T5&&ImI&cJJCi^J2 zsJxkHzrdIoe4iiYO`jj=(hpvPlS?6)DRi4_O8Thx^~{k5@Ki0=L5E6{*}~AY#+3UP=az=ln(0-ViF7xD9cKno|m%i|+vHZEit<6B!`?4~^X_7F= z`gR{kT!ZtgE%WxcuO8IUR(y&1b0tOlOsZ51>$JUXwoR%09}fmwQ6F?ZufpZrFS#*AP`=Cl5o}dJ#rMzY+DEx z!+4oSWpX}TL&x$VFi_8q(@A#z2v`(b;d&weVtv-2ccQS&^s2^jfM+(^R_qqfnlj&( zMpXwxOTxmcDRCAshuJYkiE+!bXr1RK>i|UXW>kj2^7up32@t9QIO17@0oY)ps(dMa z*-$YDT}_vLVF<+l_M7B;?U0^;2Oon7L2bx{#AD52eeQ?ZaxS0F0y}|5)lXa~IGaB} zU!(hv=ERxZns_^Qr`!ouu25W_v-;pb7_JN8hCN&(+6T2=;+L|{yE64h<`aQBLubOc zeW11K14vyh#ysDM7bE`>dFKGg+~Aeh?gJOZ*D%!1 zrY8?$vOcMT7MR#w?AL%Dk;ygnYXg9EqG4J5<(>zj4(Y1Bec`zv;!8zNs%3 z3eSkipV0{cvFJlK{{j^Kv6h@nv9u3r#`*Q(;+k<)i3kL@ZNx!e;Krksrdkt+&Rfmr zc+FUG7Khh+Lbz_4E;Os|8af2^rW;nwZrRLC={DV#ahGnX5ZwZbGQ<+&Mfy#wLhPC;KeXtVF( zF-3HD|LWm(&8Rk*_culaXvQ$vq%a`7ioHW%1yS4tZsZz=OBE#A8*HkK1@Fjek4?m- zR%B=rVOcqMemy{U)Z}E(G2hJh#!|=>DCqja+y1%cP(&-Gl9|x6y5gpMQ{KL41ECbG zPO8iN75RNF&@6>P`_`Qcxy0M)Fj|QF_)`bcQ7eN-+t6IwLRo}P2mKdu=On>z&^4Gq z4Tu29WYA2%B4=&a+(2!>aBgg>4LMz<%55{Ro#LLF&+0DFr~6;tZ~e>rpqY=;ZTcMQ zXIrhGzcb)=SRI~L5kBc;?U`P^4a$rfmHI0m1u|qv!DoFL^eaXuS^E+;3Ua{Ww=}m+ z_Uotz>UEdy-=8`cwY<{F=faP)UfFi!lK4$WwYn^i=4s4zQ`gVBQR5m+ z>$P4HKUs+CZ#NK!MU3{horsKL*D5$)pRPV!bGaB)0=(=EeGMMGRUkwzZ<21B&fUvO z6PvZlTf(-TI#shWTSFQn^jOp<_CUZJj=qy?LFCYA7vSj>UifPx*TRQo)%H@c>uOF# ziVl8n>P}B-;Ken#0G!RP4v1MZsiads8y%0Yw9+5BE-Wc`+w>R}ud?@oHG;cg6i2F~ z!(D&F4Z_>uE^sp-@en_kcb_k}8f`0gtNEAAMOF^0O&OIt!$M(C%(XFxw_g3Lbr=A- zE?b71$cHUZI;q4$l9Mx{{wke!fDpmi2SFA`FSZ0F_qi1KoQkgCI~Nn+2MR&U?KYj- z9z(UPYc++ecZG;cbhe&Wo!XGBJttbHmWVyeWcYgfmuvf|;=oeDoFS z7`u*lg?;Hat1+rZ$uj==pW>Zc19#1sD5-uHKzaBx@t@YRn=L(pW3AC+)Y>f*sWM>6 zaRd|6H)WhXOc*mATr+wgfxBgdJ67)2qNb{GCbeshv;~};L_lqpJJYhTEfXwW+x38L zxdGEW5PT936riHb)oTV2yZ|_Vwb^q_wVCPx>kM2bsfy-*ya*MofKts=;bbdRVqCMK z1Em;OlJ|f%g;GGj<={2=*o?3+=xn7g`vv&`*bK!|R|T##xCxGI2dSf$(`Dx4$3@CxG5((~p!EMz`rif5g0n6-yJdgI za)0ZHA$``S?NYxR@On&EP3zS@i?#AR+tLo6`x8b&>D0jupIX!8{Ho6aOV5&3+gyO3n+0XEJi`r&d`btXgTHs|pCsT&9c-|g*0fzdde&X#> zy6P+HC$&&eI(Qt;je_Z)(b-vtT_v_YO3C|oW2SDK9E55UW{a{KFenXumXhR+QYvyQ zOw21F!vA8saPa(#Ek5Z;X4WNZQnixt&pF?cYJK*n71{~&WJDKb33#8~!7|I6!OO9^ z!+s+=-;$*C(+bnipTAVhI-T`>3Vuvq_pSWu^r7u|Ow|kS(S+OVtArI*xGv`ll5~-l(zVxIyoZKm9AgjP2)MaMhlqq{NM#sjOQYD)rr0czml;tgskoI7-q4B zvtde+`4zXPfleF#-;tSuhs_<&%}S9-6ezJ_l?C`i146G&$0P(qdmE~$?43ogvTOF3 z;s5!M94WDp9nO&3O>ey)tm7uz&^0SP(#TtNY6l)-Txp#!!%Y=$X3Btauki9`X}D`# zyFf~?NbmH46BQ$jMm%T$6LR)*Z~zH-Sc>EeH-%VtGOVZ8oN+f_Qr6voczap*0C1?3 zFjdjYb=@lR4v%zZ(D*gRh4Oys4@6aI$b8Y?TmNW3DUBK{JaT9Z6OYyeq@JSXW6gP# zVG24n?Nfj_!%jTuv!|;m8MYqs()j2D+3H1Yv3aCWo9gslQahZYqYs<6?knz=epW;C zOf<5jp%6x~V8BPDl73(4|J;5|YE476N2Y_2`>~0GiA*`5Hpnd?;f*53ulfaS%t%Uw zud2Z%a3}$dwQ-n;$#en@D(rM5Qj`Tg`xcX`!$2p@s_JDH&ZZ2 z7r&#KZgi&b&m{M+PnTRM@cSu)4lM<=ckw8lx(YM|x*VA#-03Q5e*Go1Eub8=@#48% zX+6Cy`tNT_Q(*!xFd6lF&N0=$8xtZkPON2M5VNV2xp}Z0^L-RNj$_f>7ujO<(8%bg z%GGe38;*d@^ES8(f-P7oO76bB>gMaM2kJ50$S_z4-0{G?w9-Y-ir;< zUN2$yI!loEO9}SWsrklRu7kr2#TY!nnY4P-R=!IG``BUI!qZ8+Rtr2oeD$JAgO@wc zjmcOn@6YDwRh+>T0{hZihE@0U&9Im5YLJdx*O8EsI|ZO-VoxDmSm*p#aR1o3RP3Y! z(L!*)$whTszbg6ArnK7d%6nusNu7ae$$M+JkA9SlqP^3bY!W(*-H4V^&>vz+0KFce z68{3yVROPArd0c6vJB?uiGlFyDb`=_m}8`S(QPG!r*OlO4z%p&qqsQVcO$PamzEgu z+8N2^>II8ri(uk4LTti{lHT@E9fSnIF37Ms5XSX}@YBHL(BtvdE8lnDw-O4Z-Zn%U zC9DqZ($MM^SLik#o1c;ehYe4A?vy8EUF@W&5S=&lI&hF%ltos>^EOPfEl`PV=A@*C z=Ou`D8-p_UX_ft}sE9xRAex4d>|F+q7AEvik+28yK7I7p89;<^l#O3DNN&}-Ye;M< zwY@~XA1;M^*N2P=LI1-=s=OJ2koqO zr-y*8u`&6udZmNklyGh0o8!R2K>#RV^VT$mV-<{Vn5nILU$N$?cau#gi6g9R9AF|La4Ruj8LFU>@YpH4#%+qiw>paaQ3O{(6n;kk2(kTLxs|(qz*8`2%m%H7UVl&oB$p_^I1*{lWHG)m2|$ zDmL;Yx--hUNUiI=qW?yA_YqX`6qPi~KpIm%_zg3k;Iji65b0;eg{!0vQ}%if(#t4> zuBvsAMq^&>ACvKOU0GCb~`F^DZX%7lh{=XCvVY>N>tH7NyD5=kT#Ys~%0ZwS}BkWoFKd_mIAJ zI#zT{Z%j#@1zl?>S84UCf^we}zFWX=e=-jn_T!h>`jr8C9+ZsmUIid#OW(P|+DJAh zjww##Gjw{KtM_a9S~VLG<0dq}i73Pe z8rUa`ZehymbsVFera_&|2rBF~i)%*Lrz%zd=tyOthVy)SK?!Uw_(!V3k=FbbcBMM6 zTPi;FI1*F z-SoTx77;gjh*+hk3BtHFagE`*;YAcY3Aat_-edkNCFl#8xJ8(MhxFn}>Ixv*MEEE6 zr;mRIKfjLW8hlFqEW7Eh8WT#->c3Ht(m^>S9iQ*@u(@laC!l1VQPCwmRl#s_bnsbN z_-$tnDr21VBp9a-L%LB?hZ7A+Qs>P8h)BoE4=U1lK~#$`%gmGxrF^_zpTm(T;AZJj zS`wJc`|n;aydHvuHUEv7J|7^on)>-?6>*|Ex;6aIFdC}LT`Y;(fjiHfM zGGCEjh81283!$s1(pgm8A_$w08Fpb6&XfO02^PyIi{ID9or&^#h`Q&MDn4ejD6=~Z z?@2D{3Kc&w<3t-HcS!$A*9V<}>@U<;L%Ls`(!K|;H8m%gxs>YmXWU*|*T-$_k|#Ek zPjvu_2p%reZ(Y_Um0|LNbmuQw;+i?1Zy&0j-i3QR{C49NJYfqzN-u1Quab-p`4vm5 zx6bD4WObpj+b#tip@?yEZt zfxfQsZ{r~YYP|^FXTTl)poo`{Pd$8Bp{<9YWCk#%#5}N=?jtdZ02@$ zBocuhevMrg`T%`0=%Xm~3IF4E|8ZaFmcak^^}mdNIqjlexQ2=|Q1t6WrImnkI&uGK zRv{j%JVMym8uLK%`sQT8h9PQI%q>s^Qzn8c3x}k(F}$ zpB^4-4(v0_`>;`ze7(G<9C&-XT)2M@_@Cc2dwP_3ZvUA5#eXU_+H|uS0&j8VcsN?H z;O#y2VeVrWV+9J&Z}@JA!O;6jzS3OJ@nq|_UnWh8kd@(~m*!!f!^3Ec*G86bSIU%V zc*i4F=TA_TGYhkE;g7@ zDosW2;Q?B5?yMEp^ZlsI@rC26NSBq~j3KUF(mNeHzv@ecD(gIkf-*FXf2XPH)=(_| z{iC)Z5~HJVcDqYPG-9;LabHbhL}uvL%iUGpsTafNO0>)Rs=~-}?&seO&r8HEB2?5K z4_#F;#*yvMwn78oqrxDX#3PUCY<0 z(4-`J^IL}&dA3#q97k5%df5}K0s@1|_JC70@p;wxi_;C1mS=IfA zm+>YbXtignW2Qz|06lp|_hg)_lR}aYo)Hwhn!ZU@zLl*JBs8n>fT7Vuq{gbw-q-?$ z%{F;ZakR{s=JN8%U_Px(7=eif4Ta?%DuI`6Mqn5F{A?k~&-|CY) z-{nC=1(AW|-w4Vl@5TiOzl#2rpux!|gh%Eq{qU#~`REb( z$Vw}w7y4;zeiQ=W3MiS9G3j)ft5-^wN0Z%$nKt*>G3t%zM%qi0MO~16D7)9?
    wWw>fQLvYqJTYxpD!AY7DbHD5kVM$m$%bz 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 diff --git a/lib/backup/database.rb b/lib/backup/database.rb index 6c0029df27f..e3686947164 100644 --- a/lib/backup/database.rb +++ b/lib/backup/database.rb @@ -70,9 +70,9 @@ module Backup success = $?.success? && status.success? if errors.present? - progress.print "------ BEGIN ERRORS -----".color(:yellow) + progress.print "------ BEGIN ERRORS -----\n".color(:yellow) progress.print errors.join.color(:yellow) - progress.print "------ END ERRORS -------".color(:yellow) + progress.print "------ END ERRORS -------\n".color(:yellow) end report_success(success) @@ -89,12 +89,12 @@ module Backup Open3.popen3(ENV, *cmd) do |stdin, stdout, stderr, thread| stdin.binmode - Thread.new do + out_reader = Thread.new do data = stdout.read $stdout.write(data) end - Thread.new do + err_reader = Thread.new do until (raw_line = stderr.gets).nil? warn(raw_line) # Recent database dumps will use --if-exists with pg_dump @@ -108,8 +108,7 @@ module Backup end stdin.close - - thread.join + [thread, out_reader, err_reader].each(&:join) [thread.value, errors] end end diff --git a/lib/gitlab/cache/request_cache.rb b/lib/gitlab/cache/request_cache.rb index 6e48ca90054..3ad919fbba8 100644 --- a/lib/gitlab/cache/request_cache.rb +++ b/lib/gitlab/cache/request_cache.rb @@ -55,7 +55,7 @@ module Gitlab .join(':') end - private cache_key_method_name + private cache_key_method_name # rubocop: disable Style/AccessModifierDeclarations end end end diff --git a/lib/gitlab/ci/templates/Jobs/Browser-Performance-Testing.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Browser-Performance-Testing.gitlab-ci.yml index 8553a940bd7..5edb26a0b56 100644 --- a/lib/gitlab/ci/templates/Jobs/Browser-Performance-Testing.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Jobs/Browser-Performance-Testing.gitlab-ci.yml @@ -7,7 +7,7 @@ performance: variables: DOCKER_TLS_CERTDIR: "" SITESPEED_IMAGE: sitespeedio/sitespeed.io - SITESPEED_VERSION: 13.3.0 + SITESPEED_VERSION: 14.1.0 SITESPEED_OPTIONS: '' services: - docker:19.03.12-dind @@ -20,15 +20,15 @@ performance: fi - export CI_ENVIRONMENT_URL=$(cat environment_url.txt) - mkdir gitlab-exporter - - wget -O gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/1.0.1/index.js + - wget -O gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/1.1.0/index.js - mkdir sitespeed-results - | if [ -f .gitlab-urls.txt ] then sed -i -e 's@^@'"$CI_ENVIRONMENT_URL"'@' .gitlab-urls.txt - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io $SITESPEED_IMAGE:$SITESPEED_VERSION --plugins.add ./gitlab-exporter --outputFolder sitespeed-results .gitlab-urls.txt $SITESPEED_OPTIONS + docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io $SITESPEED_IMAGE:$SITESPEED_VERSION --plugins.add ./gitlab-exporter --cpu --outputFolder sitespeed-results .gitlab-urls.txt $SITESPEED_OPTIONS else - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io $SITESPEED_IMAGE:$SITESPEED_VERSION --plugins.add ./gitlab-exporter --outputFolder sitespeed-results "$CI_ENVIRONMENT_URL" $SITESPEED_OPTIONS + docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io $SITESPEED_IMAGE:$SITESPEED_VERSION --plugins.add ./gitlab-exporter --cpu --outputFolder sitespeed-results "$CI_ENVIRONMENT_URL" $SITESPEED_OPTIONS fi - mv sitespeed-results/data/performance.json browser-performance.json artifacts: diff --git a/lib/gitlab/ci/templates/Verify/Browser-Performance.gitlab-ci.yml b/lib/gitlab/ci/templates/Verify/Browser-Performance.gitlab-ci.yml index 9dbd9b679a8..e591e3cc1e2 100644 --- a/lib/gitlab/ci/templates/Verify/Browser-Performance.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Verify/Browser-Performance.gitlab-ci.yml @@ -12,15 +12,15 @@ performance: variables: URL: '' SITESPEED_IMAGE: sitespeedio/sitespeed.io - SITESPEED_VERSION: 13.3.0 + SITESPEED_VERSION: 14.1.0 SITESPEED_OPTIONS: '' services: - docker:stable-dind script: - mkdir gitlab-exporter - - wget -O ./gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/master/index.js + - wget -O ./gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/1.1.0/index.js - mkdir sitespeed-results - - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io $SITESPEED_IMAGE:$SITESPEED_VERSION --plugins.add ./gitlab-exporter --outputFolder sitespeed-results $URL $SITESPEED_OPTIONS + - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io $SITESPEED_IMAGE:$SITESPEED_VERSION --plugins.add ./gitlab-exporter --cpu --outputFolder sitespeed-results $URL $SITESPEED_OPTIONS - mv sitespeed-results/data/performance.json browser-performance.json artifacts: paths: diff --git a/lib/gitlab/group_search_results.rb b/lib/gitlab/group_search_results.rb index 27b2307077d..0cc3de297ba 100644 --- a/lib/gitlab/group_search_results.rb +++ b/lib/gitlab/group_search_results.rb @@ -4,10 +4,10 @@ module Gitlab class GroupSearchResults < SearchResults attr_reader :group - def initialize(current_user, query, limit_projects = nil, group:, default_project_filter: false) + def initialize(current_user, query, limit_projects = nil, group:, default_project_filter: false, filters: {}) @group = group - super(current_user, query, limit_projects, default_project_filter: default_project_filter) + super(current_user, query, limit_projects, default_project_filter: default_project_filter, filters: filters) end # rubocop:disable CodeReuse/ActiveRecord diff --git a/lib/gitlab/middleware/multipart.rb b/lib/gitlab/middleware/multipart.rb index 733214a7cce..215a1ff383b 100644 --- a/lib/gitlab/middleware/multipart.rb +++ b/lib/gitlab/middleware/multipart.rb @@ -57,7 +57,8 @@ module Gitlab yield ensure - @open_files.each(&:close) + @open_files.compact + .each(&:close) end # This function calls itself recursively @@ -122,6 +123,7 @@ module Gitlab def allowed_paths [ + Dir.tmpdir, ::FileUploader.root, ::Gitlab.config.uploads.storage_path, ::JobArtifactUploader.workhorse_upload_path, diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb index 833065547c6..ded8d4ade3f 100644 --- a/lib/gitlab/project_search_results.rb +++ b/lib/gitlab/project_search_results.rb @@ -4,11 +4,11 @@ module Gitlab class ProjectSearchResults < SearchResults attr_reader :project, :repository_ref - def initialize(current_user, query, project:, repository_ref: nil) + def initialize(current_user, query, project:, repository_ref: nil, filters: {}) @project = project @repository_ref = repository_ref.presence - super(current_user, query, [project]) + super(current_user, query, [project], filters: filters) end def objects(scope, page: nil, per_page: DEFAULT_PER_PAGE, preload_method: nil) diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index 06e8a9ec649..8e23ac6aca5 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -292,7 +292,14 @@ module Gitlab def base64_regex @base64_regex ||= /(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?/.freeze end + + def feature_flag_regex + /\A[a-z]([-_a-z0-9]*[a-z0-9])?\z/ + end + + def feature_flag_regex_message + "can contain only lowercase letters, digits, '_' and '-'. " \ + "Must start with a letter, and cannot end with '-' or '_'" + end end end - -Gitlab::Regex.prepend_if_ee('EE::Gitlab::Regex') diff --git a/lib/gitlab/request_profiler.rb b/lib/gitlab/request_profiler.rb index dd1482da40d..541d505e735 100644 --- a/lib/gitlab/request_profiler.rb +++ b/lib/gitlab/request_profiler.rb @@ -11,7 +11,7 @@ module Gitlab Profile.new(File.basename(path)) end.select(&:valid?) end - module_function :all + module_function :all # rubocop: disable Style/AccessModifierDeclarations def find(name) file_path = File.join(PROFILES_DIR, name) @@ -19,18 +19,18 @@ module Gitlab Profile.new(name) end - module_function :find + module_function :find # rubocop: disable Style/AccessModifierDeclarations def profile_token Rails.cache.fetch('profile-token') do Devise.friendly_token end end - module_function :profile_token + module_function :profile_token # rubocop: disable Style/AccessModifierDeclarations def remove_all_profiles FileUtils.rm_rf(PROFILES_DIR) end - module_function :remove_all_profiles + module_function :remove_all_profiles # rubocop: disable Style/AccessModifierDeclarations end end diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index 33f28efe284..06d8dca2f70 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -7,7 +7,7 @@ module Gitlab DEFAULT_PAGE = 1 DEFAULT_PER_PAGE = 20 - attr_reader :current_user, :query + attr_reader :current_user, :query, :filters # Limit search results by passed projects # It allows us to search only for projects user has access to @@ -19,11 +19,12 @@ module Gitlab # query attr_reader :default_project_filter - def initialize(current_user, query, limit_projects = nil, default_project_filter: false) + def initialize(current_user, query, limit_projects = nil, default_project_filter: false, filters: {}) @current_user = current_user @query = query @limit_projects = limit_projects || Project.all @default_project_filter = default_project_filter + @filters = filters end def objects(scope, page: nil, per_page: DEFAULT_PER_PAGE, without_count: true, preload_method: nil) @@ -190,6 +191,8 @@ module Gitlab else params[:search] = query end + + params[:state] = filters[:state] if filters.key?(:state) end end diff --git a/lib/uploaded_file.rb b/lib/uploaded_file.rb index 73029c934f4..9c06daa6c5a 100644 --- a/lib/uploaded_file.rb +++ b/lib/uploaded_file.rb @@ -52,8 +52,7 @@ class UploadedFile elsif path.present? file_path = File.realpath(path) - paths = Array(upload_paths) << Dir.tmpdir - unless self.allowed_path?(file_path, paths.compact) + unless self.allowed_path?(file_path, Array(upload_paths).compact) raise InvalidPathError, "insecure path used '#{file_path}'" end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c300275746c..12ca5ace02f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2957,6 +2957,9 @@ msgstr "" msgid "Any Author" msgstr "" +msgid "Any Status" +msgstr "" + msgid "Any branch" msgstr "" diff --git a/package.json b/package.json index f6583335136..ffdd715fb19 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@babel/preset-env": "^7.10.1", "@gitlab/at.js": "1.5.5", "@gitlab/svgs": "1.161.0", - "@gitlab/ui": "20.18.0", + "@gitlab/ui": "20.18.1", "@gitlab/visual-review-tools": "1.6.1", "@rails/actioncable": "^6.0.3-1", "@sentry/browser": "^5.10.2", @@ -116,7 +116,7 @@ "monaco-editor": "^0.20.0", "monaco-editor-webpack-plugin": "^1.9.0", "monaco-yaml": "^2.4.1", - "mousetrap": "^1.4.6", + "mousetrap": "1.6.5", "pdfjs-dist": "^2.0.943", "pikaday": "^1.8.0", "popper.js": "^1.16.1", diff --git a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb index 09e5196cb52..91770a00081 100644 --- a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb +++ b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb @@ -58,39 +58,6 @@ RSpec.describe Projects::MergeRequests::DiffsController do end end - shared_examples 'persisted preferred diff view cookie' do - context 'with view param' do - before do - go(view: 'parallel') - end - - it 'saves the preferred diff view in a cookie' do - expect(response.cookies['diff_view']).to eq('parallel') - end - - it 'only renders the required view', :aggregate_failures do - diff_files_without_deletions = json_response['diff_files'].reject { |f| f['deleted_file'] } - have_no_inline_diff_lines = satisfy('have no inline diff lines') do |diff_file| - !diff_file.has_key?('highlighted_diff_lines') - end - - expect(diff_files_without_deletions).to all(have_key('parallel_diff_lines')) - expect(diff_files_without_deletions).to all(have_no_inline_diff_lines) - end - end - - context 'when the user cannot view the merge request' do - before do - project.team.truncate - go - end - - it 'returns a 404' do - expect(response).to have_gitlab_http_status(:not_found) - end - end - end - shared_examples "diff note on-demand position creation" do it "updates diff discussion positions" do service = double("service") @@ -155,7 +122,6 @@ RSpec.describe Projects::MergeRequests::DiffsController do it_behaves_like 'forked project with submodules' end - it_behaves_like 'persisted preferred diff view cookie' it_behaves_like 'cached diff collection' it_behaves_like 'diff note on-demand position creation' end @@ -511,7 +477,6 @@ RSpec.describe Projects::MergeRequests::DiffsController do end it_behaves_like 'forked project with submodules' - it_behaves_like 'persisted preferred diff view cookie' it_behaves_like 'cached diff collection' context 'diff unfolding' do diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 8e1b250cd3c..14d9858318a 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -44,6 +44,16 @@ RSpec.describe Projects::MergeRequestsController do get :show, params: params.merge(extra_params) end + context 'with view param' do + before do + go(view: 'parallel') + end + + it 'saves the preferred diff view in a cookie' do + expect(response.cookies['diff_view']).to eq('parallel') + end + end + context 'when merge request is unchecked' do before do merge_request.mark_as_unchecked! diff --git a/spec/factories/clusters/kubernetes_namespaces.rb b/spec/factories/clusters/kubernetes_namespaces.rb index c820bf4da60..efcb3abcb90 100644 --- a/spec/factories/clusters/kubernetes_namespaces.rb +++ b/spec/factories/clusters/kubernetes_namespaces.rb @@ -10,15 +10,18 @@ FactoryBot.define do if cluster.project_type? cluster_project = cluster.cluster_project - kubernetes_namespace.project = cluster_project.project + kubernetes_namespace.project = cluster_project&.project kubernetes_namespace.cluster_project = cluster_project end - kubernetes_namespace.namespace ||= - Gitlab::Kubernetes::DefaultNamespace.new( - cluster, - project: kubernetes_namespace.project - ).from_environment_slug(kubernetes_namespace.environment&.slug) + if kubernetes_namespace.project + kubernetes_namespace.namespace ||= + Gitlab::Kubernetes::DefaultNamespace.new( + cluster, + project: kubernetes_namespace.project + ).from_environment_slug(kubernetes_namespace.environment&.slug) + end + kubernetes_namespace.service_account_name ||= "#{kubernetes_namespace.namespace}-service-account" end diff --git a/spec/factories/draft_note.rb b/spec/factories/draft_note.rb index 24563dc92b7..67a3377a39f 100644 --- a/spec/factories/draft_note.rb +++ b/spec/factories/draft_note.rb @@ -25,7 +25,7 @@ FactoryBot.define do factory :draft_note_on_discussion, traits: [:on_discussion] trait :on_discussion do - discussion_id { create(:discussion_note_on_merge_request, noteable: merge_request, project: project).discussion_id } + discussion_id { association(:discussion_note_on_merge_request, noteable: merge_request, project: project).discussion_id } end end end diff --git a/spec/factories/file_uploaders.rb b/spec/factories/file_uploaders.rb index dc888fdd535..f7ceb800f14 100644 --- a/spec/factories/file_uploaders.rb +++ b/spec/factories/file_uploaders.rb @@ -14,7 +14,7 @@ FactoryBot.define do end after(:build) do |uploader, evaluator| - uploader.store!(evaluator.file) + uploader.store!(evaluator.file) if evaluator.project&.persisted? end initialize_with do diff --git a/spec/factories/operations/feature_flag_scopes.rb b/spec/factories/operations/feature_flag_scopes.rb new file mode 100644 index 00000000000..a98c397b8b5 --- /dev/null +++ b/spec/factories/operations/feature_flag_scopes.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :operations_feature_flag_scope, class: 'Operations::FeatureFlagScope' do + association :feature_flag, factory: :operations_feature_flag + active { true } + strategies { [{ name: "default", parameters: {} }] } + sequence(:environment_scope) { |n| "review/patch-#{n}" } + end +end diff --git a/spec/factories/operations/feature_flags.rb b/spec/factories/operations/feature_flags.rb new file mode 100644 index 00000000000..7e43d38a04f --- /dev/null +++ b/spec/factories/operations/feature_flags.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :operations_feature_flag, class: 'Operations::FeatureFlag' do + sequence(:name) { |n| "feature_flag_#{n}" } + project + active { true } + + trait :legacy_flag do + version { Operations::FeatureFlag.versions['legacy_flag'] } + end + + trait :new_version_flag do + version { Operations::FeatureFlag.versions['new_version_flag'] } + end + end +end diff --git a/spec/factories/operations/feature_flags/scope.rb b/spec/factories/operations/feature_flags/scope.rb new file mode 100644 index 00000000000..ef0097c6d08 --- /dev/null +++ b/spec/factories/operations/feature_flags/scope.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :operations_scope, class: 'Operations::FeatureFlags::Scope' do + association :strategy, factory: :operations_strategy + sequence(:environment_scope) { |n| "review/patch-#{n}" } + end +end diff --git a/spec/factories/operations/feature_flags/strategy.rb b/spec/factories/operations/feature_flags/strategy.rb new file mode 100644 index 00000000000..bdb5d9f0f3c --- /dev/null +++ b/spec/factories/operations/feature_flags/strategy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :operations_strategy, class: 'Operations::FeatureFlags::Strategy' do + association :feature_flag, factory: :operations_feature_flag + name { "default" } + parameters { {} } + end +end diff --git a/spec/factories/operations/feature_flags/user_list.rb b/spec/factories/operations/feature_flags/user_list.rb new file mode 100644 index 00000000000..e87598f0d7c --- /dev/null +++ b/spec/factories/operations/feature_flags/user_list.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :operations_feature_flag_user_list, class: 'Operations::FeatureFlags::UserList' do + association :project, factory: :project + name { 'My User List' } + user_xids { 'user1,user2,user3' } + end +end diff --git a/spec/factories/operations/feature_flags_clients.rb b/spec/factories/operations/feature_flags_clients.rb new file mode 100644 index 00000000000..ca9a28dcfed --- /dev/null +++ b/spec/factories/operations/feature_flags_clients.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :operations_feature_flags_client, class: 'Operations::FeatureFlagsClient' do + project + end +end diff --git a/spec/frontend/search/components/state_filter_spec.js b/spec/frontend/search/components/state_filter_spec.js new file mode 100644 index 00000000000..8d203b3946a --- /dev/null +++ b/spec/frontend/search/components/state_filter_spec.js @@ -0,0 +1,81 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import StateFilter from '~/search/state_filter/components/state_filter.vue'; +import { FILTER_STATES } from '~/search/state_filter/constants'; +import * as urlUtils from '~/lib/utils/url_utility'; + +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), + setUrlParams: jest.fn(), +})); + +function createComponent(props = { scope: 'issues' }) { + return shallowMount(StateFilter, { + propsData: { + ...props, + }, + }); +} + +describe('StateFilter', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findGlDropdown = () => wrapper.find(GlDropdown); + const findGlDropdownItems = () => findGlDropdown().findAll(GlDropdownItem); + const findDropdownItemsText = () => findGlDropdownItems().wrappers.map(w => w.text()); + const firstDropDownItem = () => findGlDropdownItems().at(0); + + describe('template', () => { + describe.each` + scope | showStateDropdown + ${'issues'} | ${true} + ${'projects'} | ${false} + ${'milestones'} | ${false} + ${'users'} | ${false} + ${'merge_requests'} | ${false} + ${'notes'} | ${false} + ${'wiki_blobs'} | ${false} + ${'blobs'} | ${false} + `(`state dropdown`, ({ scope, showStateDropdown }) => { + beforeEach(() => { + wrapper = createComponent({ scope }); + }); + + it(`does${showStateDropdown ? '' : ' not'} render when scope is ${scope}`, () => { + expect(findGlDropdown().exists()).toBe(showStateDropdown); + }); + }); + + describe('Filter options', () => { + it('renders a dropdown item for each filterOption', () => { + expect(findDropdownItemsText()).toStrictEqual( + Object.keys(FILTER_STATES).map(key => { + return FILTER_STATES[key].label; + }), + ); + }); + + it('clicking a dropdown item calls setUrlParams', () => { + const state = FILTER_STATES[Object.keys(FILTER_STATES)[0]].value; + firstDropDownItem().vm.$emit('click'); + + expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ state }); + }); + + it('clicking a dropdown item calls visitUrl', () => { + firstDropDownItem().vm.$emit('click'); + + expect(urlUtils.visitUrl).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/file_finder/index_spec.js b/spec/frontend/vue_shared/components/file_finder/index_spec.js index f9e56774526..43208568ac1 100644 --- a/spec/frontend/vue_shared/components/file_finder/index_spec.js +++ b/spec/frontend/vue_shared/components/file_finder/index_spec.js @@ -343,26 +343,36 @@ describe('File finder item spec', () => { it('always allows `command+p` to trigger toggle', () => { expect( - vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'command+p'), + Mousetrap.prototype.stopCallback( + null, + vm.$el.querySelector('.dropdown-input-field'), + 'command+p', + ), ).toBe(false); }); it('always allows `ctrl+p` to trigger toggle', () => { expect( - vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'ctrl+p'), + Mousetrap.prototype.stopCallback( + null, + vm.$el.querySelector('.dropdown-input-field'), + 'ctrl+p', + ), ).toBe(false); }); it('onlys handles `t` when focused in input-field', () => { expect( - vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 't'), + Mousetrap.prototype.stopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 't'), ).toBe(true); }); it('stops callback in monaco editor', () => { setFixtures('
    '); - expect(vm.mousetrapStopCallback(null, document.querySelector('.inputarea'), 't')).toBe(true); + expect( + Mousetrap.prototype.stopCallback(null, document.querySelector('.inputarea'), 't'), + ).toBe(true); }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js index 440a93eaf64..2681488f76a 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -8,12 +8,27 @@ import { } from '@gitlab/ui'; import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { uniqueTokens } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; import { SortDirection } from '~/vue_shared/components/filtered_search_bar/constants'; import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; -import { mockAvailableTokens, mockSortOptions, mockHistoryItems } from './mock_data'; +import { + mockAvailableTokens, + mockSortOptions, + mockHistoryItems, + tokenValueAuthor, + tokenValueLabel, + tokenValueMilestone, +} from './mock_data'; + +jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({ + uniqueTokens: jest.fn().mockImplementation(tokens => tokens), + stripQuotes: jest.requireActual( + '~/vue_shared/components/filtered_search_bar/filtered_search_utils', + ).stripQuotes, +})); const createComponent = ({ shallow = true, @@ -73,13 +88,21 @@ describe('FilteredSearchBarRoot', () => { describe('computed', () => { describe('tokenSymbols', () => { it('returns a map containing type and symbols from `tokens` prop', () => { - expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@', label_name: '~' }); + expect(wrapper.vm.tokenSymbols).toEqual({ + author_username: '@', + label_name: '~', + milestone_title: '%', + }); }); }); describe('tokenTitles', () => { it('returns a map containing type and title from `tokens` prop', () => { - expect(wrapper.vm.tokenTitles).toEqual({ author_username: 'Author', label_name: 'Label' }); + expect(wrapper.vm.tokenTitles).toEqual({ + author_username: 'Author', + label_name: 'Label', + milestone_title: 'Milestone', + }); }); }); @@ -131,6 +154,20 @@ describe('FilteredSearchBarRoot', () => { expect(wrapper.vm.filteredRecentSearches[0]).toEqual({ foo: 'bar' }); }); + it('returns array of recent searches sanitizing any duplicate token values', async () => { + wrapper.setData({ + recentSearches: [ + [tokenValueAuthor, tokenValueLabel, tokenValueMilestone, tokenValueLabel], + [tokenValueAuthor, tokenValueMilestone], + ], + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.filteredRecentSearches).toHaveLength(2); + expect(uniqueTokens).toHaveBeenCalled(); + }); + it('returns undefined when recentSearchesStorageKey prop is not set on component', async () => { wrapper.setProps({ recentSearchesStorageKey: '', @@ -182,40 +219,12 @@ describe('FilteredSearchBarRoot', () => { }); describe('removeQuotesEnclosure', () => { - const mockFilters = [ - { - type: 'author_username', - value: { - data: 'root', - operator: '=', - }, - }, - { - type: 'label_name', - value: { - data: '"Documentation Update"', - operator: '=', - }, - }, - 'foo', - ]; + const mockFilters = [tokenValueAuthor, tokenValueLabel, 'foo']; it('returns filter array with unescaped strings for values which have spaces', () => { expect(wrapper.vm.removeQuotesEnclosure(mockFilters)).toEqual([ - { - type: 'author_username', - value: { - data: 'root', - operator: '=', - }, - }, - { - type: 'label_name', - value: { - data: 'Documentation Update', - operator: '=', - }, - }, + tokenValueAuthor, + tokenValueLabel, 'foo', ]); }); @@ -277,21 +286,26 @@ describe('FilteredSearchBarRoot', () => { }); describe('handleFilterSubmit', () => { - const mockFilters = [ - { - type: 'author_username', - value: { - data: 'root', - operator: '=', - }, - }, - 'foo', - ]; + const mockFilters = [tokenValueAuthor, 'foo']; + + beforeEach(async () => { + wrapper.setData({ + filterValue: mockFilters, + }); + + await wrapper.vm.$nextTick(); + }); + + it('calls `uniqueTokens` on `filterValue` prop to remove duplicates', () => { + wrapper.vm.handleFilterSubmit(); + + expect(uniqueTokens).toHaveBeenCalledWith(wrapper.vm.filterValue); + }); it('calls `recentSearchesStore.addRecentSearch` with serialized value of provided `filters` param', () => { jest.spyOn(wrapper.vm.recentSearchesStore, 'addRecentSearch'); - wrapper.vm.handleFilterSubmit(mockFilters); + wrapper.vm.handleFilterSubmit(); return wrapper.vm.recentSearchesPromise.then(() => { expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith(mockFilters); @@ -301,7 +315,7 @@ describe('FilteredSearchBarRoot', () => { it('calls `recentSearchesService.save` with array of searches', () => { jest.spyOn(wrapper.vm.recentSearchesService, 'save'); - wrapper.vm.handleFilterSubmit(mockFilters); + wrapper.vm.handleFilterSubmit(); return wrapper.vm.recentSearchesPromise.then(() => { expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([mockFilters]); @@ -311,7 +325,7 @@ describe('FilteredSearchBarRoot', () => { it('sets `recentSearches` data prop with array of searches', () => { jest.spyOn(wrapper.vm.recentSearchesService, 'save'); - wrapper.vm.handleFilterSubmit(mockFilters); + wrapper.vm.handleFilterSubmit(); return wrapper.vm.recentSearchesPromise.then(() => { expect(wrapper.vm.recentSearches).toEqual([mockFilters]); @@ -329,7 +343,7 @@ describe('FilteredSearchBarRoot', () => { it('emits component event `onFilter` with provided filters param', () => { jest.spyOn(wrapper.vm, 'removeQuotesEnclosure'); - wrapper.vm.handleFilterSubmit(mockFilters); + wrapper.vm.handleFilterSubmit(); expect(wrapper.emitted('onFilter')[0]).toEqual([mockFilters]); expect(wrapper.vm.removeQuotesEnclosure).toHaveBeenCalledWith(mockFilters); @@ -366,7 +380,9 @@ describe('FilteredSearchBarRoot', () => { '.gl-search-box-by-click-menu .gl-search-box-by-click-history-item', ); - expect(searchHistoryItemsEl.at(0).text()).toBe('Author := @tobyLabel := ~Bug"duo"'); + expect(searchHistoryItemsEl.at(0).text()).toBe( + 'Author := @rootLabel := ~bugMilestone := %v1.0"duo"', + ); wrapperFullMount.destroy(); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js index a857f84adf1..14ffd7b2d85 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js @@ -1,5 +1,12 @@ import * as filteredSearchUtils from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; +import { + tokenValueAuthor, + tokenValueLabel, + tokenValueMilestone, + tokenValuePlain, +} from './mock_data'; + describe('Filtered Search Utils', () => { describe('stripQuotes', () => { it.each` @@ -9,6 +16,7 @@ describe('Filtered Search Utils', () => { ${'FooBar'} | ${'FooBar'} ${"Foo'Bar"} | ${"Foo'Bar"} ${'Foo"Bar'} | ${'Foo"Bar'} + ${'Foo Bar'} | ${'Foo Bar'} `( 'returns string $outputValue when called with string $inputValue', ({ inputValue, outputValue }) => { @@ -16,4 +24,29 @@ describe('Filtered Search Utils', () => { }, ); }); + + describe('uniqueTokens', () => { + it('returns tokens array with duplicates removed', () => { + expect( + filteredSearchUtils.uniqueTokens([ + tokenValueAuthor, + tokenValueLabel, + tokenValueMilestone, + tokenValueLabel, + tokenValuePlain, + ]), + ).toHaveLength(4); // Removes 2nd instance of tokenValueLabel + }); + + it('returns tokens array as it is if it does not have duplicates', () => { + expect( + filteredSearchUtils.uniqueTokens([ + tokenValueAuthor, + tokenValueLabel, + tokenValueMilestone, + tokenValuePlain, + ]), + ).toHaveLength(4); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index dcccb1f49b6..0eb90f5529d 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -89,36 +89,40 @@ export const mockMilestoneToken = { fetchMilestones: () => Promise.resolve({ data: mockMilestones }), }; -export const mockAvailableTokens = [mockAuthorToken, mockLabelToken]; +export const mockAvailableTokens = [mockAuthorToken, mockLabelToken, mockMilestoneToken]; + +export const tokenValueAuthor = { + type: 'author_username', + value: { + data: 'root', + operator: '=', + }, +}; + +export const tokenValueLabel = { + type: 'label_name', + value: { + operator: '=', + data: 'bug', + }, +}; + +export const tokenValueMilestone = { + type: 'milestone_title', + value: { + operator: '=', + data: 'v1.0', + }, +}; + +export const tokenValuePlain = { + type: 'filtered-search-term', + value: { data: 'foo' }, +}; export const mockHistoryItems = [ - [ - { - type: 'author_username', - value: { - data: 'toby', - operator: '=', - }, - }, - { - type: 'label_name', - value: { - data: 'Bug', - operator: '=', - }, - }, - 'duo', - ], - [ - { - type: 'author_username', - value: { - data: 'root', - operator: '=', - }, - }, - 'si', - ], + [tokenValueAuthor, tokenValueLabel, tokenValueMilestone, 'duo'], + [tokenValueAuthor, 'si'], ]; export const mockSortOptions = [ diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js index e9bc482a8fc..c7eabaf3e8d 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js @@ -157,7 +157,7 @@ describe('MilestoneToken', () => { const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); expect(tokenSegments).toHaveLength(3); // Milestone, =, '%"4.0"' - expect(tokenSegments.at(2).text()).toBe(`%"${mockRegularMilestone.title}"`); // "4.0 RC1" + expect(tokenSegments.at(2).text()).toBe(`%${mockRegularMilestone.title}`); // "4.0 RC1" }); it('renders provided defaultMilestones as suggestions', async () => { diff --git a/spec/lib/gitlab/group_search_results_spec.rb b/spec/lib/gitlab/group_search_results_spec.rb index 3c3410c41bf..726df37e3aa 100644 --- a/spec/lib/gitlab/group_search_results_spec.rb +++ b/spec/lib/gitlab/group_search_results_spec.rb @@ -6,9 +6,22 @@ RSpec.describe Gitlab::GroupSearchResults do # group creation calls GroupFinder, so need to create the group # before so expect(GroupsFinder) check works let_it_be(:group) { create(:group) } - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } + let(:filters) { {} } + let(:limit_projects) { Project.all } + let(:query) { 'gob' } - subject(:results) { described_class.new(user, 'gob', anything, group: group) } + subject(:results) { described_class.new(user, query, limit_projects, group: group, filters: filters) } + + describe 'issues search' do + let_it_be(:project) { create(:project, :public, group: group) } + let_it_be(:opened_issue) { create(:issue, :opened, project: project, title: 'foo opened') } + let_it_be(:closed_issue) { create(:issue, :closed, project: project, title: 'foo closed') } + let(:query) { 'foo' } + let(:filters) { { state: 'opened' } } + + include_examples 'search issues scope filters by state' + end describe 'user search' do subject(:objects) { results.objects('users') } diff --git a/spec/lib/gitlab/middleware/multipart/handler_spec.rb b/spec/lib/gitlab/middleware/multipart/handler_spec.rb new file mode 100644 index 00000000000..aac3f00defe --- /dev/null +++ b/spec/lib/gitlab/middleware/multipart/handler_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Middleware::Multipart::Handler do + using RSpec::Parameterized::TableSyntax + + let_it_be(:env) { Rack::MockRequest.env_for('/', method: 'post', params: {}) } + let_it_be(:message) { { 'rewritten_fields' => {} } } + + describe '#allowed_paths' do + let_it_be(:expected_allowed_paths) do + [ + Dir.tmpdir, + ::FileUploader.root, + ::Gitlab.config.uploads.storage_path, + ::JobArtifactUploader.workhorse_upload_path, + ::LfsObjectUploader.workhorse_upload_path, + File.join(Rails.root, 'public/uploads/tmp') + ] + end + + let_it_be(:expected_with_packages_path) { expected_allowed_paths + [::Packages::PackageFileUploader.workhorse_upload_path] } + + subject { described_class.new(env, message).send(:allowed_paths) } + + where(:package_features_enabled, :object_storage_enabled, :direct_upload_enabled, :expected_paths) do + false | false | true | :expected_allowed_paths + false | false | false | :expected_allowed_paths + false | true | true | :expected_allowed_paths + false | true | false | :expected_allowed_paths + true | false | true | :expected_with_packages_path + true | false | false | :expected_with_packages_path + true | true | true | :expected_allowed_paths + true | true | false | :expected_with_packages_path + end + + with_them do + before do + stub_config(packages: { + enabled: package_features_enabled, + object_store: { + enabled: object_storage_enabled, + direct_upload: direct_upload_enabled + }, + storage_path: '/any/dir' + }) + end + + it { is_expected.to eq(send(expected_paths)) } + end + end +end diff --git a/spec/lib/gitlab/middleware/multipart_spec.rb b/spec/lib/gitlab/middleware/multipart_spec.rb index 3b64fe335e8..781f3e0289b 100644 --- a/spec/lib/gitlab/middleware/multipart_spec.rb +++ b/spec/lib/gitlab/middleware/multipart_spec.rb @@ -2,311 +2,138 @@ require 'spec_helper' -require 'tempfile' - RSpec.describe Gitlab::Middleware::Multipart do - include_context 'multipart middleware context' - - RSpec.shared_examples_for 'multipart upload files' do - it 'opens top-level files' do - Tempfile.open('top-level') do |tempfile| - rewritten = { 'file' => tempfile.path } - in_params = { 'file.name' => original_filename, 'file.path' => file_path, 'file.remote_id' => remote_id, 'file.size' => file_size } - env = post_env(rewritten, in_params, Gitlab::Workhorse.secret, 'gitlab-workhorse') - - expect_uploaded_file(tempfile, %w(file)) + include MultipartHelpers - middleware.call(env) - end + describe '#call' do + let(:app) { double(:app) } + let(:middleware) { described_class.new(app) } + let(:secret) { Gitlab::Workhorse.secret } + let(:issuer) { 'gitlab-workhorse' } + + subject do + env = post_env( + rewritten_fields: rewritten_fields, + params: params, + secret: secret, + issuer: issuer + ) + middleware.call(env) end - it 'opens files one level deep' do - Tempfile.open('one-level') do |tempfile| - rewritten = { 'user[avatar]' => tempfile.path } - in_params = { 'user' => { 'avatar' => { '.name' => original_filename, '.path' => file_path, '.remote_id' => remote_id, '.size' => file_size } } } - env = post_env(rewritten, in_params, Gitlab::Workhorse.secret, 'gitlab-workhorse') + context 'with remote file mode params' do + let(:mode) { :remote } - expect_uploaded_file(tempfile, %w(user avatar)) + it_behaves_like 'handling all upload parameters conditions' - middleware.call(env) - end - end + context 'and a path set' do + include_context 'with one temporary file for multipart' - it 'opens files two levels deep' do - Tempfile.open('two-levels') do |tempfile| - in_params = { 'project' => { 'milestone' => { 'themesong' => { '.name' => original_filename, '.path' => file_path, '.remote_id' => remote_id, '.size' => file_size } } } } - rewritten = { 'project[milestone][themesong]' => tempfile.path } - env = post_env(rewritten, in_params, Gitlab::Workhorse.secret, 'gitlab-workhorse') + let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) } + let(:params) { upload_parameters_for(key: 'file', filename: filename, remote_id: remote_id).merge('file.path' => '/should/not/be/read') } - expect_uploaded_file(tempfile, %w(project milestone themesong)) - - middleware.call(env) - end - end + it 'builds an UploadedFile' do + expect_uploaded_files(original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(file)) - def expect_uploaded_file(tempfile, path) - expect(app).to receive(:call) do |env| - file = get_params(env).dig(*path) - expect(file).to be_a(::UploadedFile) - expect(file.original_filename).to eq(original_filename) - - if remote_id - expect(file.remote_id).to eq(remote_id) - expect(file.path).to be_nil - else - expect(file.path).to eq(File.realpath(tempfile.path)) - expect(file.remote_id).to be_nil + subject end end end - end - RSpec.shared_examples_for 'handling CI artifact upload' do - it 'uploads both file and metadata' do - Tempfile.open('file') do |file| - Tempfile.open('metadata') do |metadata| - rewritten = { 'file' => file.path, 'metadata' => metadata.path } - in_params = { 'file.name' => 'file.txt', 'file.path' => file_path, 'file.remote_id' => file_remote_id, 'file.size' => file_size, 'metadata.name' => 'metadata.gz' } - env = post_env(rewritten, in_params, Gitlab::Workhorse.secret, 'gitlab-workhorse') - - with_expected_uploaded_artifact_files(file, metadata) do |uploaded_file, uploaded_metadata| - expect(uploaded_file).to be_a(::UploadedFile) - expect(uploaded_file.original_filename).to eq('file.txt') - - if file_remote_id - expect(uploaded_file.remote_id).to eq(file_remote_id) - expect(uploaded_file.size).to eq(file_size) - expect(uploaded_file.path).to be_nil - else - expect(uploaded_file.path).to eq(File.realpath(file.path)) - expect(uploaded_file.remote_id).to be_nil - end - - expect(uploaded_metadata).to be_a(::UploadedFile) - expect(uploaded_metadata.original_filename).to eq('metadata.gz') - expect(uploaded_metadata.path).to eq(File.realpath(metadata.path)) - expect(uploaded_metadata.remote_id).to be_nil - end - - middleware.call(env) - end - end - end + context 'local file mode' do + let(:mode) { :local } - def with_expected_uploaded_artifact_files(file, metadata) - expect(app).to receive(:call) do |env| - file = get_params(env).dig('file') - metadata = get_params(env).dig('metadata') + it_behaves_like 'handling all upload parameters conditions' - yield file, metadata - end - end - end + context 'when file is' do + include_context 'with one temporary file for multipart' - it 'rejects headers signed with the wrong secret' do - env = post_env({ 'file' => '/var/empty/nonesuch' }, {}, 'x' * 32, 'gitlab-workhorse') + let(:allowed_paths) { [Dir.tmpdir] } - expect { middleware.call(env) }.to raise_error(JWT::VerificationError) - end - - it 'rejects headers signed with the wrong issuer' do - env = post_env({ 'file' => '/var/empty/nonesuch' }, {}, Gitlab::Workhorse.secret, 'acme-inc') - - expect { middleware.call(env) }.to raise_error(JWT::InvalidIssuerError) - end - - context 'with invalid rewritten field' do - invalid_field_names = [ - '[file]', - ';file', - 'file]', - ';file]', - 'file]]', - 'file;;' - ] - - invalid_field_names.each do |invalid_field_name| - it "rejects invalid rewritten field name #{invalid_field_name}" do - env = post_env({ invalid_field_name => nil }, {}, Gitlab::Workhorse.secret, 'gitlab-workhorse') - - expect { middleware.call(env) }.to raise_error(RuntimeError, "invalid field: \"#{invalid_field_name}\"") - end - end - end - - context 'with remote file' do - let(:remote_id) { 'someid' } - let(:file_size) { 300 } - let(:file_path) { '' } - - it_behaves_like 'multipart upload files' - end - - context 'with remote file and a file path set' do - let(:remote_id) { 'someid' } - let(:file_size) { 300 } - let(:file_path) { 'not_a_valid_file_path' } # file path will come from the rewritten_fields - - it_behaves_like 'multipart upload files' - end + before do + expect_next_instance_of(::Gitlab::Middleware::Multipart::Handler) do |handler| + expect(handler).to receive(:allowed_paths).and_return(allowed_paths) + end + end - context 'with local file' do - let(:remote_id) { nil } - let(:file_size) { nil } - let(:file_path) { 'not_a_valid_file_path' } # file path will come from the rewritten_fields + context 'in allowed paths' do + let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) } + let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'file', filename: filename, remote_id: remote_id) } - it_behaves_like 'multipart upload files' - end + it 'builds an UploadedFile' do + expect_uploaded_files(filepath: uploaded_filepath, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(file)) - context 'with remote CI artifact upload' do - let(:file_remote_id) { 'someid' } - let(:file_size) { 300 } - let(:file_path) { 'not_a_valid_file_path' } # file path will come from the rewritten_fields + subject + end + end - it_behaves_like 'handling CI artifact upload' - end + context 'not in allowed paths' do + let(:allowed_paths) { [] } - context 'with local CI artifact upload' do - let(:file_remote_id) { nil } - let(:file_size) { nil } - let(:file_path) { 'not_a_valid_file_path' } # file path will come from the rewritten_fields + let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) } + let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'file') } - it_behaves_like 'handling CI artifact upload' - end + it 'returns an error' do + result = subject - it 'allows files in uploads/tmp directory' do - with_tmp_dir('public/uploads/tmp') do |dir, env| - expect(app).to receive(:call) do |env| - expect(get_params(env)['file']).to be_a(::UploadedFile) + expect(result[0]).to eq(400) + expect(result[2]).to include('insecure path used') + end + end end - - middleware.call(env) end - end - it 'allows files in the job artifact upload path' do - with_tmp_dir('artifacts') do |dir, env| - expect(JobArtifactUploader).to receive(:workhorse_upload_path).and_return(File.join(dir, 'artifacts')) - expect(app).to receive(:call) do |env| - expect(get_params(env)['file']).to be_a(::UploadedFile) - end + context 'with dummy params in remote mode' do + let(:rewritten_fields) { { 'file' => 'should/not/be/read' } } + let(:params) { upload_parameters_for(key: 'file') } + let(:mode) { :remote } - middleware.call(env) - end - end + context 'with an invalid secret' do + let(:secret) { 'INVALID_SECRET' } - it 'allows files in the lfs upload path' do - with_tmp_dir('lfs-objects') do |dir, env| - expect(LfsObjectUploader).to receive(:workhorse_upload_path).and_return(File.join(dir, 'lfs-objects')) - expect(app).to receive(:call) do |env| - expect(get_params(env)['file']).to be_a(::UploadedFile) + it { expect { subject }.to raise_error(JWT::VerificationError) } end - middleware.call(env) - end - end + context 'with an invalid issuer' do + let(:issuer) { 'INVALID_ISSUER' } - it 'allows symlinks for uploads dir' do - Tempfile.open('two-levels') do |tempfile| - symlinked_dir = '/some/dir/uploads' - symlinked_path = File.join(symlinked_dir, File.basename(tempfile.path)) - env = post_env({ 'file' => symlinked_path }, { 'file.name' => original_filename, 'file.path' => symlinked_path }, Gitlab::Workhorse.secret, 'gitlab-workhorse') - - allow(FileUploader).to receive(:root).and_return(symlinked_dir) - allow(UploadedFile).to receive(:allowed_paths).and_return([symlinked_dir, Gitlab.config.uploads.storage_path]) - allow(File).to receive(:realpath).and_call_original - allow(File).to receive(:realpath).with(symlinked_dir).and_return(Dir.tmpdir) - allow(File).to receive(:realpath).with(symlinked_path).and_return(tempfile.path) - allow(File).to receive(:exist?).and_call_original - allow(File).to receive(:exist?).with(symlinked_dir).and_return(true) - - # override Dir.tmpdir because this dir is in the list of allowed paths - # and it would match FileUploader.root path (which in this test is linked - # to /tmp too) - allow(Dir).to receive(:tmpdir).and_return(File.join(Dir.tmpdir, 'tmpsubdir')) - - expect(app).to receive(:call) do |env| - expect(get_params(env)['file']).to be_a(::UploadedFile) + it { expect { subject }.to raise_error(JWT::InvalidIssuerError) } end - middleware.call(env) - end - end - - describe '#call' do - context 'with packages storage' do - using RSpec::Parameterized::TableSyntax - - let(:storage_path) { 'shared/packages' } - - RSpec.shared_examples 'allowing the multipart upload' do - it 'allows files to be uploaded' do - with_tmp_dir('tmp/uploads', storage_path) do |dir, env| - allow(Packages::PackageFileUploader).to receive(:root).and_return(File.join(dir, storage_path)) + context 'with invalid rewritten field key' do + invalid_keys = [ + '[file]', + ';file', + 'file]', + ';file]', + 'file]]', + 'file;;' + ] - expect(app).to receive(:call) do |env| - expect(get_params(env)['file']).to be_a(::UploadedFile) - end + invalid_keys.each do |invalid_key| + context invalid_key do + let(:rewritten_fields) { { invalid_key => 'should/not/be/read' } } - middleware.call(env) + it { expect { subject }.to raise_error(RuntimeError, "invalid field: \"#{invalid_key}\"") } end end end - RSpec.shared_examples 'not allowing the multipart upload when package upload path is used' do - it 'does not allow files to be uploaded' do - with_tmp_dir('tmp/uploads', storage_path) do |dir, env| - # with_tmp_dir sets the same workhorse_upload_path for all Uploaders, - # so we have to prevent JobArtifactUploader and LfsObjectUploader to - # allow the tested path - allow(JobArtifactUploader).to receive(:workhorse_upload_path).and_return(Dir.tmpdir) - allow(LfsObjectUploader).to receive(:workhorse_upload_path).and_return(Dir.tmpdir) + context 'with invalid key in parameters' do + include_context 'with one temporary file for multipart' - status, headers, body = middleware.call(env) + let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) } + let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'wrong_key', filename: filename, remote_id: remote_id) } - expect(status).to eq(400) - expect(headers).to eq({ 'Content-Type' => 'text/plain' }) - expect(body).to start_with('insecure path used') + it 'builds no UploadedFile' do + expect(app).to receive(:call) do |env| + received_params = get_params(env) + expect(received_params['file']).to be_nil + expect(received_params['wrong_key']).to be_nil end - end - end - RSpec.shared_examples 'adding package storage to multipart allowed paths' do - before do - expect(::Packages::PackageFileUploader).to receive(:workhorse_upload_path).and_call_original + subject end - - it_behaves_like 'allowing the multipart upload' - end - - RSpec.shared_examples 'not adding package storage to multipart allowed paths' do - before do - expect(::Packages::PackageFileUploader).not_to receive(:workhorse_upload_path) - end - - it_behaves_like 'not allowing the multipart upload when package upload path is used' - end - - where(:object_storage_enabled, :direct_upload_enabled, :example_name) do - false | true | 'adding package storage to multipart allowed paths' - false | false | 'adding package storage to multipart allowed paths' - true | true | 'not adding package storage to multipart allowed paths' - true | false | 'adding package storage to multipart allowed paths' - end - - with_them do - before do - stub_config(packages: { - enabled: true, - object_store: { - enabled: object_storage_enabled, - direct_upload: direct_upload_enabled - }, - storage_path: storage_path - }) - end - - it_behaves_like params[:example_name] end end end diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index ea66363469a..22383cd993c 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -5,12 +5,13 @@ require 'spec_helper' RSpec.describe Gitlab::ProjectSearchResults do include SearchHelpers - let(:user) { create(:user) } - let(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } let(:query) { 'hello world' } let(:repository_ref) { nil } + let(:filters) { {} } - subject(:results) { described_class.new(user, query, project: project, repository_ref: repository_ref) } + subject(:results) { described_class.new(user, query, project: project, repository_ref: repository_ref, filters: filters) } context 'with a repository_ref' do context 'when empty' do @@ -258,6 +259,24 @@ RSpec.describe Gitlab::ProjectSearchResults do describe "confidential issues" do include_examples "access restricted confidential issues" end + + context 'filtering' do + let_it_be(:project) { create(:project, :public) } + let_it_be(:closed_issue) { create(:issue, :closed, project: project, title: 'foo closed') } + let_it_be(:opened_issue) { create(:issue, :opened, project: project, title: 'foo opened') } + let(:query) { 'foo' } + + include_examples 'search issues scope filters by state' + end + + it 'filters issues when state is provided', :aggregate_failures do + closed_issue = create(:issue, :closed, project: project, title: "Revert: #{issue.title}") + + results = described_class.new(project.creator, query, project: project, filters: { state: 'opened' }) + + expect(results.objects('issues')).not_to include closed_issue + expect(results.objects('issues')).to include issue + end end describe 'notes search' do diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index c5563027a84..13942493cc5 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -6,13 +6,14 @@ RSpec.describe Gitlab::SearchResults do include ProjectForksHelper include SearchHelpers - let(:user) { create(:user) } - let!(:project) { create(:project, name: 'foo') } - let!(:issue) { create(:issue, project: project, title: 'foo') } - let!(:merge_request) { create(:merge_request, source_project: project, title: 'foo') } - let!(:milestone) { create(:milestone, project: project, title: 'foo') } + let_it_be(:user) { create(:user) } + 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') } + let(:merge_request) { create(:merge_request, source_project: project, title: 'foo') } + let(:filters) { {} } - subject(:results) { described_class.new(user, 'foo', Project.all) } + subject(:results) { described_class.new(user, 'foo', Project.all, filters: filters) } context 'as a user with access' do before do @@ -105,10 +106,10 @@ RSpec.describe Gitlab::SearchResults do describe '#limited_issues_count' do it 'runs single SQL query to get the limited amount of issues' do - create(:milestone, project: project, title: 'foo2') + create(:issue, project: project, title: 'foo2') expect(results).to receive(:issues).with(public_only: true).and_call_original - expect(results).not_to receive(:issues).with(no_args).and_call_original + expect(results).not_to receive(:issues).with(no_args) expect(results.limited_issues_count).to eq(1) end @@ -165,6 +166,13 @@ RSpec.describe Gitlab::SearchResults do results.objects('issues') end + + context 'filtering' do + let_it_be(:closed_issue) { create(:issue, :closed, project: project, title: 'foo closed') } + let_it_be(:opened_issue) { create(:issue, :opened, project: project, title: 'foo open') } + + include_examples 'search issues scope filters by state' + end end describe '#users' do diff --git a/spec/lib/uploaded_file_spec.rb b/spec/lib/uploaded_file_spec.rb index 5ff46193b4f..cf2ab04b457 100644 --- a/spec/lib/uploaded_file_spec.rb +++ b/spec/lib/uploaded_file_spec.rb @@ -23,7 +23,7 @@ RSpec.describe UploadedFile do end subject do - described_class.from_params(params, :file, upload_path, file_path_override) + described_class.from_params(params, :file, [upload_path, Dir.tmpdir], file_path_override) end context 'when valid file is specified' do diff --git a/spec/models/operations/feature_flag_scope_spec.rb b/spec/models/operations/feature_flag_scope_spec.rb new file mode 100644 index 00000000000..29d338d8b29 --- /dev/null +++ b/spec/models/operations/feature_flag_scope_spec.rb @@ -0,0 +1,391 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Operations::FeatureFlagScope do + describe 'associations' do + it { is_expected.to belong_to(:feature_flag) } + end + + describe 'validations' do + context 'when duplicate environment scope is going to be created' do + let!(:existing_feature_flag_scope) do + create(:operations_feature_flag_scope) + end + + let(:new_feature_flag_scope) do + build(:operations_feature_flag_scope, + feature_flag: existing_feature_flag_scope.feature_flag, + environment_scope: existing_feature_flag_scope.environment_scope) + end + + it 'validates uniqueness of environment scope' do + new_feature_flag_scope.save + + expect(new_feature_flag_scope.errors[:environment_scope]) + .to include("(#{existing_feature_flag_scope.environment_scope})" \ + " has already been taken") + end + end + + context 'when environment scope of a default scope is updated' do + let!(:feature_flag) { create(:operations_feature_flag) } + let!(:scope_default) { feature_flag.default_scope } + + it 'keeps default scope intact' do + scope_default.update(environment_scope: 'review/*') + + expect(scope_default.errors[:environment_scope]) + .to include("cannot be changed from default scope") + end + end + + context 'when a default scope is destroyed' do + let!(:feature_flag) { create(:operations_feature_flag) } + let!(:scope_default) { feature_flag.default_scope } + + it 'prevents from destroying the default scope' do + expect { scope_default.destroy! }.to raise_error(ActiveRecord::ReadOnlyRecord) + end + end + + describe 'strategy validations' do + it 'handles null strategies which can occur while adding the column during migration' do + scope = create(:operations_feature_flag_scope, active: true) + allow(scope).to receive(:strategies).and_return(nil) + + scope.active = false + scope.save + + expect(scope.errors[:strategies]).to be_empty + end + + it 'validates multiple strategies' do + feature_flag = create(:operations_feature_flag) + scope = described_class.create(feature_flag: feature_flag, + environment_scope: 'production', active: true, + strategies: [{ name: "default", parameters: {} }, + { name: "invalid", parameters: {} }]) + + expect(scope.errors[:strategies]).not_to be_empty + end + + where(:invalid_value) do + [{}, 600, "bad", [{ name: 'default', parameters: {} }, 300]] + end + with_them do + it 'must be an array of strategy hashes' do + scope = create(:operations_feature_flag_scope) + + scope.strategies = invalid_value + scope.save + + expect(scope.errors[:strategies]).to eq(['must be an array of strategy hashes']) + end + end + + describe 'name' do + using RSpec::Parameterized::TableSyntax + + where(:name, :params, :expected) do + 'default' | {} | [] + 'gradualRolloutUserId' | { groupId: 'mygroup', percentage: '50' } | [] + 'userWithId' | { userIds: 'sam' } | [] + 5 | nil | ['strategy name is invalid'] + nil | nil | ['strategy name is invalid'] + "nothing" | nil | ['strategy name is invalid'] + "" | nil | ['strategy name is invalid'] + 40.0 | nil | ['strategy name is invalid'] + {} | nil | ['strategy name is invalid'] + [] | nil | ['strategy name is invalid'] + end + with_them do + it 'must be one of "default", "gradualRolloutUserId", or "userWithId"' do + feature_flag = create(:operations_feature_flag) + scope = described_class.create(feature_flag: feature_flag, + environment_scope: 'production', active: true, + strategies: [{ name: name, parameters: params }]) + + expect(scope.errors[:strategies]).to eq(expected) + end + end + end + + describe 'parameters' do + context 'when the strategy name is gradualRolloutUserId' do + it 'must have parameters' do + feature_flag = create(:operations_feature_flag) + scope = described_class.create(feature_flag: feature_flag, + environment_scope: 'production', active: true, + strategies: [{ name: 'gradualRolloutUserId' }]) + + expect(scope.errors[:strategies]).to eq(['parameters are invalid']) + end + + where(:invalid_parameters) do + [nil, {}, { percentage: '40', groupId: 'mygroup', userIds: '4' }, { percentage: '40' }, + { percentage: '40', groupId: 'mygroup', extra: nil }, { groupId: 'mygroup' }] + end + with_them do + it 'must have valid parameters for the strategy' do + feature_flag = create(:operations_feature_flag) + scope = described_class.create(feature_flag: feature_flag, + environment_scope: 'production', active: true, + strategies: [{ name: 'gradualRolloutUserId', + parameters: invalid_parameters }]) + + expect(scope.errors[:strategies]).to eq(['parameters are invalid']) + end + end + + it 'allows the parameters in any order' do + feature_flag = create(:operations_feature_flag) + scope = described_class.create(feature_flag: feature_flag, + environment_scope: 'production', active: true, + strategies: [{ name: 'gradualRolloutUserId', + parameters: { percentage: '10', groupId: 'mygroup' } }]) + + expect(scope.errors[:strategies]).to be_empty + end + + describe 'percentage' do + where(:invalid_value) do + [50, 40.0, { key: "value" }, "garbage", "00", "01", "101", "-1", "-10", "0100", + "1000", "10.0", "5%", "25%", "100hi", "e100", "30m", " ", "\r\n", "\n", "\t", + "\n10", "20\n", "\n100", "100\n", "\n ", nil] + end + with_them do + it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do + feature_flag = create(:operations_feature_flag) + scope = described_class.create(feature_flag: feature_flag, + environment_scope: 'production', active: true, + strategies: [{ name: 'gradualRolloutUserId', + parameters: { groupId: 'mygroup', percentage: invalid_value } }]) + + expect(scope.errors[:strategies]).to eq(['percentage must be a string between 0 and 100 inclusive']) + end + end + + where(:valid_value) do + %w[0 1 10 38 100 93] + end + with_them do + it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do + feature_flag = create(:operations_feature_flag) + scope = described_class.create(feature_flag: feature_flag, + environment_scope: 'production', active: true, + strategies: [{ name: 'gradualRolloutUserId', + parameters: { groupId: 'mygroup', percentage: valid_value } }]) + + expect(scope.errors[:strategies]).to eq([]) + end + end + end + + describe 'groupId' do + where(:invalid_value) do + [nil, 4, 50.0, {}, 'spaces bad', 'bad$', '%bad', '', '!bad', + '.bad', 'Bad', 'bad1', "", " ", "b" * 33, "ba_d", "ba\nd"] + end + with_them do + it 'must be a string value of up to 32 lowercase characters' do + feature_flag = create(:operations_feature_flag) + scope = described_class.create(feature_flag: feature_flag, + environment_scope: 'production', active: true, + strategies: [{ name: 'gradualRolloutUserId', + parameters: { groupId: invalid_value, percentage: '40' } }]) + + expect(scope.errors[:strategies]).to eq(['groupId parameter is invalid']) + end + end + + where(:valid_value) do + ["somegroup", "anothergroup", "okay", "g", "a" * 32] + end + with_them do + it 'must be a string value of up to 32 lowercase characters' do + feature_flag = create(:operations_feature_flag) + scope = described_class.create(feature_flag: feature_flag, + environment_scope: 'production', active: true, + strategies: [{ name: 'gradualRolloutUserId', + parameters: { groupId: valid_value, percentage: '40' } }]) + + expect(scope.errors[:strategies]).to eq([]) + end + end + end + end + + context 'when the strategy name is userWithId' do + it 'must have parameters' do + feature_flag = create(:operations_feature_flag) + scope = described_class.create(feature_flag: feature_flag, + environment_scope: 'production', active: true, + strategies: [{ name: 'userWithId' }]) + + expect(scope.errors[:strategies]).to eq(['parameters are invalid']) + end + + where(:invalid_parameters) do + [nil, { userIds: 'sam', percentage: '40' }, { userIds: 'sam', some: 'param' }, { percentage: '40' }, {}] + end + with_them do + it 'must have valid parameters for the strategy' do + feature_flag = create(:operations_feature_flag) + scope = described_class.create(feature_flag: feature_flag, + environment_scope: 'production', active: true, + strategies: [{ name: 'userWithId', parameters: invalid_parameters }]) + + expect(scope.errors[:strategies]).to eq(['parameters are invalid']) + end + end + + describe 'userIds' do + where(:valid_value) do + ["", "sam", "1", "a", "uuid-of-some-kind", "sam,fred,tom,jane,joe,mike", + "gitlab@example.com", "123,4", "UPPER,Case,charActeRS", "0", + "$valid$email#2345#$%..{}+=-)?\\/@example.com", "spaces allowed", + "a" * 256, "a,#{'b' * 256},ccc", "many spaces"] + end + with_them do + it 'is valid with a string of comma separated values' do + feature_flag = create(:operations_feature_flag) + scope = described_class.create(feature_flag: feature_flag, + environment_scope: 'production', active: true, + strategies: [{ name: 'userWithId', parameters: { userIds: valid_value } }]) + + expect(scope.errors[:strategies]).to be_empty + end + end + + where(:invalid_value) do + [1, 2.5, {}, [], nil, "123\n456", "1,2,3,12\t3", "\n", "\n\r", + "joe\r,sam", "1,2,2", "1,,2", "1,2,,,,", "b" * 257, "1, ,2", "tim, ,7", " ", + " ", " ,1", "1, ", " leading,1", "1,trailing ", "1, both ,2"] + end + with_them do + it 'is invalid' do + feature_flag = create(:operations_feature_flag) + scope = described_class.create(feature_flag: feature_flag, + environment_scope: 'production', active: true, + strategies: [{ name: 'userWithId', parameters: { userIds: invalid_value } }]) + + expect(scope.errors[:strategies]).to include( + 'userIds must be a string of unique comma separated values each 256 characters or less' + ) + end + end + end + end + + context 'when the strategy name is default' do + it 'must have parameters' do + feature_flag = create(:operations_feature_flag) + scope = described_class.create(feature_flag: feature_flag, + environment_scope: 'production', active: true, + strategies: [{ name: 'default' }]) + + expect(scope.errors[:strategies]).to eq(['parameters are invalid']) + end + + where(:invalid_value) do + [{ groupId: "hi", percentage: "7" }, "", "nothing", 7, nil, [], 2.5] + end + with_them do + it 'must be empty' do + feature_flag = create(:operations_feature_flag) + scope = described_class.create(feature_flag: feature_flag, + environment_scope: 'production', active: true, + strategies: [{ name: 'default', + parameters: invalid_value }]) + + expect(scope.errors[:strategies]).to eq(['parameters are invalid']) + end + end + + it 'must be empty' do + feature_flag = create(:operations_feature_flag) + scope = described_class.create(feature_flag: feature_flag, + environment_scope: 'production', active: true, + strategies: [{ name: 'default', + parameters: {} }]) + + expect(scope.errors[:strategies]).to be_empty + end + end + end + end + end + + describe '.enabled' do + subject { described_class.enabled } + + let!(:feature_flag_scope) do + create(:operations_feature_flag_scope, active: active) + end + + context 'when scope is active' do + let(:active) { true } + + it 'returns the scope' do + is_expected.to include(feature_flag_scope) + end + end + + context 'when scope is inactive' do + let(:active) { false } + + it 'returns an empty array' do + is_expected.not_to include(feature_flag_scope) + end + end + end + + describe '.disabled' do + subject { described_class.disabled } + + let!(:feature_flag_scope) do + create(:operations_feature_flag_scope, active: active) + end + + context 'when scope is active' do + let(:active) { true } + + it 'returns an empty array' do + is_expected.not_to include(feature_flag_scope) + end + end + + context 'when scope is inactive' do + let(:active) { false } + + it 'returns the scope' do + is_expected.to include(feature_flag_scope) + end + end + end + + describe '.for_unleash_client' do + it 'returns scopes for the specified project' do + project1 = create(:project) + project2 = create(:project) + expected_feature_flag = create(:operations_feature_flag, project: project1) + create(:operations_feature_flag, project: project2) + + scopes = described_class.for_unleash_client(project1, 'sandbox').to_a + + expect(scopes).to contain_exactly(*expected_feature_flag.scopes) + end + + it 'returns a scope that matches exactly over a match with a wild card' do + project = create(:project) + feature_flag = create(:operations_feature_flag, project: project) + create(:operations_feature_flag_scope, feature_flag: feature_flag, environment_scope: 'production*') + expected_scope = create(:operations_feature_flag_scope, feature_flag: feature_flag, environment_scope: 'production') + + scopes = described_class.for_unleash_client(project, 'production').to_a + + expect(scopes).to contain_exactly(expected_scope) + end + end +end diff --git a/spec/models/operations/feature_flag_spec.rb b/spec/models/operations/feature_flag_spec.rb new file mode 100644 index 00000000000..83d6c6b95a3 --- /dev/null +++ b/spec/models/operations/feature_flag_spec.rb @@ -0,0 +1,258 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Operations::FeatureFlag do + include FeatureFlagHelpers + + subject { create(:operations_feature_flag) } + + describe 'associations' do + it { is_expected.to belong_to(:project) } + it { is_expected.to have_many(:scopes) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:project) } + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) } + it { is_expected.to define_enum_for(:version).with_values(legacy_flag: 1, new_version_flag: 2) } + + context 'a version 1 feature flag' do + it 'is valid if associated with Operations::FeatureFlagScope models' do + project = create(:project) + feature_flag = described_class.create({ name: 'test', project: project, version: 1, + scopes_attributes: [{ environment_scope: '*', active: false }] }) + + expect(feature_flag).to be_valid + end + + it 'is invalid if associated with Operations::FeatureFlags::Strategy models' do + project = create(:project) + feature_flag = described_class.create({ name: 'test', project: project, version: 1, + strategies_attributes: [{ name: 'default', parameters: {} }] }) + + expect(feature_flag.errors.messages).to eq({ + version_associations: ["version 1 feature flags may not have strategies"] + }) + end + end + + context 'a version 2 feature flag' do + it 'is invalid if associated with Operations::FeatureFlagScope models' do + project = create(:project) + feature_flag = described_class.create({ name: 'test', project: project, version: 2, + scopes_attributes: [{ environment_scope: '*', active: false }] }) + + expect(feature_flag.errors.messages).to eq({ + version_associations: ["version 2 feature flags may not have scopes"] + }) + end + + it 'is valid if associated with Operations::FeatureFlags::Strategy models' do + project = create(:project) + feature_flag = described_class.create({ name: 'test', project: project, version: 2, + strategies_attributes: [{ name: 'default', parameters: {} }] }) + + expect(feature_flag).to be_valid + end + end + + it_behaves_like 'AtomicInternalId', validate_presence: true do + let(:internal_id_attribute) { :iid } + let(:instance) { build(:operations_feature_flag) } + let(:scope) { :project } + let(:scope_attrs) { { project: instance.project } } + let(:usage) { :operations_feature_flags } + end + end + + describe 'feature flag version' do + it 'defaults to 1 if unspecified' do + project = create(:project) + + feature_flag = described_class.create(name: 'my_flag', project: project, active: true) + + expect(feature_flag).to be_valid + expect(feature_flag.version_before_type_cast).to eq(1) + end + end + + describe 'Scope creation' do + subject { described_class.new(**params) } + + let(:project) { create(:project) } + + let(:params) do + { name: 'test', project: project, scopes_attributes: scopes_attributes } + end + + let(:scopes_attributes) do + [{ environment_scope: '*', active: false }, + { environment_scope: 'review/*', active: true }] + end + + it { is_expected.to be_valid } + + context 'when the first scope is not wildcard' do + let(:scopes_attributes) do + [{ environment_scope: 'review/*', active: true }, + { environment_scope: '*', active: false }] + end + + it { is_expected.not_to be_valid } + end + end + + describe 'the default scope' do + let_it_be(:project) { create(:project) } + + context 'with a version 1 feature flag' do + it 'creates a default scope' do + feature_flag = described_class.create({ name: 'test', project: project, scopes_attributes: [], version: 1 }) + + expect(feature_flag.scopes.count).to eq(1) + expect(feature_flag.scopes.first.environment_scope).to eq('*') + end + + it 'allows specifying the default scope in the parameters' do + feature_flag = described_class.create({ name: 'test', project: project, + scopes_attributes: [{ environment_scope: '*', active: false }, + { environment_scope: 'review/*', active: true }], version: 1 }) + + expect(feature_flag.scopes.count).to eq(2) + expect(feature_flag.scopes.first.environment_scope).to eq('*') + end + end + + context 'with a version 2 feature flag' do + it 'does not create a default scope' do + feature_flag = described_class.create({ name: 'test', project: project, scopes_attributes: [], version: 2 }) + + expect(feature_flag.scopes).to eq([]) + end + end + end + + describe '.enabled' do + subject { described_class.enabled } + + context 'when the feature flag is active' do + let!(:feature_flag) { create(:operations_feature_flag, active: true) } + + it 'returns the flag' do + is_expected.to eq([feature_flag]) + end + end + + context 'when the feature flag is active and all scopes are inactive' do + let!(:feature_flag) { create(:operations_feature_flag, active: true) } + + it 'returns the flag' do + feature_flag.default_scope.update!(active: false) + + is_expected.to eq([feature_flag]) + end + end + + context 'when the feature flag is inactive' do + let!(:feature_flag) { create(:operations_feature_flag, active: false) } + + it 'does not return the flag' do + is_expected.to be_empty + end + end + + context 'when the feature flag is inactive and all scopes are active' do + let!(:feature_flag) { create(:operations_feature_flag, active: false) } + + it 'does not return the flag' do + feature_flag.default_scope.update!(active: true) + + is_expected.to be_empty + end + end + end + + describe '.disabled' do + subject { described_class.disabled } + + context 'when the feature flag is active' do + let!(:feature_flag) { create(:operations_feature_flag, active: true) } + + it 'does not return the flag' do + is_expected.to be_empty + end + end + + context 'when the feature flag is active and all scopes are inactive' do + let!(:feature_flag) { create(:operations_feature_flag, active: true) } + + it 'does not return the flag' do + feature_flag.default_scope.update!(active: false) + + is_expected.to be_empty + end + end + + context 'when the feature flag is inactive' do + let!(:feature_flag) { create(:operations_feature_flag, active: false) } + + it 'returns the flag' do + is_expected.to eq([feature_flag]) + end + end + + context 'when the feature flag is inactive and all scopes are active' do + let!(:feature_flag) { create(:operations_feature_flag, active: false) } + + it 'returns the flag' do + feature_flag.default_scope.update!(active: true) + + is_expected.to eq([feature_flag]) + end + end + end + + describe '.for_unleash_client' do + let_it_be(:project) { create(:project) } + let!(:feature_flag) do + create(:operations_feature_flag, project: project, + name: 'feature1', active: true, version: 2) + end + + let!(:strategy) do + create(:operations_strategy, feature_flag: feature_flag, + name: 'default', parameters: {}) + end + + it 'matches wild cards in the scope' do + create(:operations_scope, strategy: strategy, environment_scope: 'review/*') + + flags = described_class.for_unleash_client(project, 'review/feature-branch') + + expect(flags).to eq([feature_flag]) + end + + it 'matches wild cards case sensitively' do + create(:operations_scope, strategy: strategy, environment_scope: 'Staging/*') + + flags = described_class.for_unleash_client(project, 'staging/feature') + + expect(flags).to eq([]) + end + + it 'returns feature flags ordered by id' do + create(:operations_scope, strategy: strategy, environment_scope: 'production') + feature_flag_b = create(:operations_feature_flag, project: project, + name: 'feature2', active: true, version: 2) + strategy_b = create(:operations_strategy, feature_flag: feature_flag_b, + name: 'default', parameters: {}) + create(:operations_scope, strategy: strategy_b, environment_scope: '*') + + flags = described_class.for_unleash_client(project, 'production') + + expect(flags.map(&:id)).to eq([feature_flag.id, feature_flag_b.id]) + end + end +end diff --git a/spec/models/operations/feature_flags/strategy_spec.rb b/spec/models/operations/feature_flags/strategy_spec.rb new file mode 100644 index 00000000000..04e3ef26e9d --- /dev/null +++ b/spec/models/operations/feature_flags/strategy_spec.rb @@ -0,0 +1,323 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Operations::FeatureFlags::Strategy do + let_it_be(:project) { create(:project) } + + describe 'validations' do + it do + is_expected.to validate_inclusion_of(:name) + .in_array(%w[default gradualRolloutUserId userWithId gitlabUserList]) + .with_message('strategy name is invalid') + end + + describe 'parameters' do + context 'when the strategy name is invalid' do + where(:invalid_name) do + [nil, {}, [], 'nothing', 3] + end + with_them do + it 'skips parameters validation' do + feature_flag = create(:operations_feature_flag, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: invalid_name, parameters: { bad: 'params' }) + + expect(strategy.errors[:name]).to eq(['strategy name is invalid']) + expect(strategy.errors[:parameters]).to be_empty + end + end + end + + context 'when the strategy name is gradualRolloutUserId' do + where(:invalid_parameters) do + [nil, {}, { percentage: '40', groupId: 'mygroup', userIds: '4' }, { percentage: '40' }, + { percentage: '40', groupId: 'mygroup', extra: nil }, { groupId: 'mygroup' }] + end + with_them do + it 'must have valid parameters for the strategy' do + feature_flag = create(:operations_feature_flag, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'gradualRolloutUserId', parameters: invalid_parameters) + + expect(strategy.errors[:parameters]).to eq(['parameters are invalid']) + end + end + + it 'allows the parameters in any order' do + feature_flag = create(:operations_feature_flag, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'gradualRolloutUserId', + parameters: { percentage: '10', groupId: 'mygroup' }) + + expect(strategy.errors[:parameters]).to be_empty + end + + describe 'percentage' do + where(:invalid_value) do + [50, 40.0, { key: "value" }, "garbage", "00", "01", "101", "-1", "-10", "0100", + "1000", "10.0", "5%", "25%", "100hi", "e100", "30m", " ", "\r\n", "\n", "\t", + "\n10", "20\n", "\n100", "100\n", "\n ", nil] + end + with_them do + it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do + feature_flag = create(:operations_feature_flag, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'gradualRolloutUserId', + parameters: { groupId: 'mygroup', percentage: invalid_value }) + + expect(strategy.errors[:parameters]).to eq(['percentage must be a string between 0 and 100 inclusive']) + end + end + + where(:valid_value) do + %w[0 1 10 38 100 93] + end + with_them do + it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do + feature_flag = create(:operations_feature_flag, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'gradualRolloutUserId', + parameters: { groupId: 'mygroup', percentage: valid_value }) + + expect(strategy.errors[:parameters]).to eq([]) + end + end + end + + describe 'groupId' do + where(:invalid_value) do + [nil, 4, 50.0, {}, 'spaces bad', 'bad$', '%bad', '', '!bad', + '.bad', 'Bad', 'bad1', "", " ", "b" * 33, "ba_d", "ba\nd"] + end + with_them do + it 'must be a string value of up to 32 lowercase characters' do + feature_flag = create(:operations_feature_flag, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'gradualRolloutUserId', + parameters: { groupId: invalid_value, percentage: '40' }) + + expect(strategy.errors[:parameters]).to eq(['groupId parameter is invalid']) + end + end + + where(:valid_value) do + ["somegroup", "anothergroup", "okay", "g", "a" * 32] + end + with_them do + it 'must be a string value of up to 32 lowercase characters' do + feature_flag = create(:operations_feature_flag, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'gradualRolloutUserId', + parameters: { groupId: valid_value, percentage: '40' }) + + expect(strategy.errors[:parameters]).to eq([]) + end + end + end + end + + context 'when the strategy name is userWithId' do + where(:invalid_parameters) do + [nil, { userIds: 'sam', percentage: '40' }, { userIds: 'sam', some: 'param' }, { percentage: '40' }, {}] + end + with_them do + it 'must have valid parameters for the strategy' do + feature_flag = create(:operations_feature_flag, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'userWithId', parameters: invalid_parameters) + + expect(strategy.errors[:parameters]).to eq(['parameters are invalid']) + end + end + + describe 'userIds' do + where(:valid_value) do + ["", "sam", "1", "a", "uuid-of-some-kind", "sam,fred,tom,jane,joe,mike", + "gitlab@example.com", "123,4", "UPPER,Case,charActeRS", "0", + "$valid$email#2345#$%..{}+=-)?\\/@example.com", "spaces allowed", + "a" * 256, "a,#{'b' * 256},ccc", "many spaces"] + end + with_them do + it 'is valid with a string of comma separated values' do + feature_flag = create(:operations_feature_flag, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'userWithId', parameters: { userIds: valid_value }) + + expect(strategy.errors[:parameters]).to be_empty + end + end + + where(:invalid_value) do + [1, 2.5, {}, [], nil, "123\n456", "1,2,3,12\t3", "\n", "\n\r", + "joe\r,sam", "1,2,2", "1,,2", "1,2,,,,", "b" * 257, "1, ,2", "tim, ,7", " ", + " ", " ,1", "1, ", " leading,1", "1,trailing ", "1, both ,2"] + end + with_them do + it 'is invalid' do + feature_flag = create(:operations_feature_flag, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'userWithId', parameters: { userIds: invalid_value }) + + expect(strategy.errors[:parameters]).to include( + 'userIds must be a string of unique comma separated values each 256 characters or less' + ) + end + end + end + end + + context 'when the strategy name is default' do + where(:invalid_value) do + [{ groupId: "hi", percentage: "7" }, "", "nothing", 7, nil, [], 2.5] + end + with_them do + it 'must be empty' do + feature_flag = create(:operations_feature_flag, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'default', + parameters: invalid_value) + + expect(strategy.errors[:parameters]).to eq(['parameters are invalid']) + end + end + + it 'must be empty' do + feature_flag = create(:operations_feature_flag, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'default', + parameters: {}) + + expect(strategy.errors[:parameters]).to be_empty + end + end + + context 'when the strategy name is gitlabUserList' do + where(:invalid_value) do + [{ groupId: "default", percentage: "7" }, "", "nothing", 7, nil, [], 2.5, { userIds: 'user1' }] + end + with_them do + it 'must be empty' do + feature_flag = create(:operations_feature_flag, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'gitlabUserList', + parameters: invalid_value) + + expect(strategy.errors[:parameters]).to eq(['parameters are invalid']) + end + end + + it 'must be empty' do + feature_flag = create(:operations_feature_flag, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'gitlabUserList', + parameters: {}) + + expect(strategy.errors[:parameters]).to be_empty + end + end + end + + describe 'associations' do + context 'when name is gitlabUserList' do + it 'is valid when associated with a user list' do + feature_flag = create(:operations_feature_flag, project: project) + user_list = create(:operations_feature_flag_user_list, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'gitlabUserList', + user_list: user_list, + parameters: {}) + + expect(strategy.errors[:user_list]).to be_empty + end + + it 'is invalid without a user list' do + feature_flag = create(:operations_feature_flag, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'gitlabUserList', + parameters: {}) + + expect(strategy.errors[:user_list]).to eq(["can't be blank"]) + end + + it 'is invalid when associated with a user list from another project' do + other_project = create(:project) + feature_flag = create(:operations_feature_flag, project: project) + user_list = create(:operations_feature_flag_user_list, project: other_project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'gitlabUserList', + user_list: user_list, + parameters: {}) + + expect(strategy.errors[:user_list]).to eq(['must belong to the same project']) + end + end + + context 'when name is default' do + it 'is invalid when associated with a user list' do + feature_flag = create(:operations_feature_flag, project: project) + user_list = create(:operations_feature_flag_user_list, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'default', + user_list: user_list, + parameters: {}) + + expect(strategy.errors[:user_list]).to eq(['must be blank']) + end + + it 'is valid without a user list' do + feature_flag = create(:operations_feature_flag, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'default', + parameters: {}) + + expect(strategy.errors[:user_list]).to be_empty + end + end + + context 'when name is userWithId' do + it 'is invalid when associated with a user list' do + feature_flag = create(:operations_feature_flag, project: project) + user_list = create(:operations_feature_flag_user_list, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'userWithId', + user_list: user_list, + parameters: { userIds: 'user1' }) + + expect(strategy.errors[:user_list]).to eq(['must be blank']) + end + + it 'is valid without a user list' do + feature_flag = create(:operations_feature_flag, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'userWithId', + parameters: { userIds: 'user1' }) + + expect(strategy.errors[:user_list]).to be_empty + end + end + + context 'when name is gradualRolloutUserId' do + it 'is invalid when associated with a user list' do + feature_flag = create(:operations_feature_flag, project: project) + user_list = create(:operations_feature_flag_user_list, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'gradualRolloutUserId', + user_list: user_list, + parameters: { groupId: 'default', percentage: '10' }) + + expect(strategy.errors[:user_list]).to eq(['must be blank']) + end + + it 'is valid without a user list' do + feature_flag = create(:operations_feature_flag, project: project) + strategy = described_class.create(feature_flag: feature_flag, + name: 'gradualRolloutUserId', + parameters: { groupId: 'default', percentage: '10' }) + + expect(strategy.errors[:user_list]).to be_empty + end + end + end + end +end diff --git a/spec/models/operations/feature_flags/user_list_spec.rb b/spec/models/operations/feature_flags/user_list_spec.rb new file mode 100644 index 00000000000..020416aa7bc --- /dev/null +++ b/spec/models/operations/feature_flags/user_list_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Operations::FeatureFlags::UserList do + subject { create(:operations_feature_flag_user_list) } + + describe 'validations' do + it { is_expected.to validate_presence_of(:project) } + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) } + it { is_expected.to validate_length_of(:name).is_at_least(1).is_at_most(255) } + + describe 'user_xids' do + where(:valid_value) do + ["", "sam", "1", "a", "uuid-of-some-kind", "sam,fred,tom,jane,joe,mike", + "gitlab@example.com", "123,4", "UPPER,Case,charActeRS", "0", + "$valid$email#2345#$%..{}+=-)?\\/@example.com", "spaces allowed", + "a" * 256, "a,#{'b' * 256},ccc", "many spaces"] + end + with_them do + it 'is valid with a string of comma separated values' do + user_list = described_class.create(user_xids: valid_value) + + expect(user_list.errors[:user_xids]).to be_empty + end + end + + where(:typecast_value) do + [1, 2.5, {}, []] + end + with_them do + it 'automatically casts values of other types' do + user_list = described_class.create(user_xids: typecast_value) + + expect(user_list.errors[:user_xids]).to be_empty + expect(user_list.user_xids).to eq(typecast_value.to_s) + end + end + + where(:invalid_value) do + [nil, "123\n456", "1,2,3,12\t3", "\n", "\n\r", + "joe\r,sam", "1,2,2", "1,,2", "1,2,,,,", "b" * 257, "1, ,2", "tim, ,7", " ", + " ", " ,1", "1, ", " leading,1", "1,trailing ", "1, both ,2"] + end + with_them do + it 'is invalid' do + user_list = described_class.create(user_xids: invalid_value) + + expect(user_list.errors[:user_xids]).to include( + 'user_xids must be a string of unique comma separated values each 256 characters or less' + ) + end + end + end + end + + describe 'url_helpers' do + it 'generates paths based on the internal id' do + create(:operations_feature_flag_user_list) + project_b = create(:project) + list_b = create(:operations_feature_flag_user_list, project: project_b) + + path = ::Gitlab::Routing.url_helpers.project_feature_flags_user_list_path(project_b, list_b) + + expect(path).to eq("/#{project_b.full_path}/-/feature_flags_user_lists/#{list_b.iid}") + end + end + + describe '#destroy' do + it 'deletes the model if it is not associated with any feature flag strategies' do + project = create(:project) + user_list = described_class.create(project: project, name: 'My User List', user_xids: 'user1,user2') + + user_list.destroy + + expect(described_class.count).to eq(0) + end + + it 'does not delete the model if it is associated with a feature flag strategy' do + project = create(:project) + user_list = described_class.create(project: project, name: 'My User List', user_xids: 'user1,user2') + feature_flag = create(:operations_feature_flag, :new_version_flag, project: project) + strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'gitlabUserList', user_list: user_list) + + user_list.destroy + + expect(described_class.count).to eq(1) + expect(::Operations::FeatureFlags::StrategyUserList.count).to eq(1) + expect(strategy.reload.user_list).to eq(user_list) + expect(strategy.valid?).to eq(true) + end + end + + it_behaves_like 'AtomicInternalId' do + let(:internal_id_attribute) { :iid } + let(:instance) { build(:operations_feature_flag_user_list) } + let(:scope) { :project } + let(:scope_attrs) { { project: instance.project } } + let(:usage) { :operations_user_lists } + end +end diff --git a/spec/models/operations/feature_flags_client_spec.rb b/spec/models/operations/feature_flags_client_spec.rb new file mode 100644 index 00000000000..05988d676f3 --- /dev/null +++ b/spec/models/operations/feature_flags_client_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Operations::FeatureFlagsClient do + subject { create(:operations_feature_flags_client) } + + describe 'associations' do + it { is_expected.to belong_to(:project) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:project) } + end + + describe '#token' do + it "ensures that token is always set" do + expect(subject.token).not_to be_empty + end + end +end diff --git a/spec/services/error_tracking/list_projects_service_spec.rb b/spec/services/error_tracking/list_projects_service_spec.rb index 8bc632349fa..ce391bd1ca0 100644 --- a/spec/services/error_tracking/list_projects_service_spec.rb +++ b/spec/services/error_tracking/list_projects_service_spec.rb @@ -121,7 +121,7 @@ RSpec.describe ErrorTracking::ListProjectsService do end context 'error_tracking_setting is nil' do - let(:error_tracking_setting) { build(:project_error_tracking_setting) } + let(:error_tracking_setting) { build(:project_error_tracking_setting, project: project) } let(:new_api_url) { new_api_host + 'api/0/projects/org/proj/' } before do diff --git a/spec/support/forgery_protection.rb b/spec/support/forgery_protection.rb index 1d6ea013292..d12e99b17c4 100644 --- a/spec/support/forgery_protection.rb +++ b/spec/support/forgery_protection.rb @@ -8,7 +8,7 @@ module ForgeryProtection ActionController::Base.allow_forgery_protection = false end - module_function :with_forgery_protection + module_function :with_forgery_protection # rubocop: disable Style/AccessModifierDeclarations end RSpec.configure do |config| diff --git a/spec/support/helpers/feature_flag_helpers.rb b/spec/support/helpers/feature_flag_helpers.rb new file mode 100644 index 00000000000..93cd915879b --- /dev/null +++ b/spec/support/helpers/feature_flag_helpers.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module FeatureFlagHelpers + def create_flag(project, name, active = true, description: nil, version: Operations::FeatureFlag.versions['legacy_flag']) + create(:operations_feature_flag, name: name, active: active, version: version, + description: description, project: project) + end + + def create_scope(feature_flag, environment_scope, active = true, strategies = [{ name: "default", parameters: {} }]) + create(:operations_feature_flag_scope, + feature_flag: feature_flag, + environment_scope: environment_scope, + active: active, + strategies: strategies) + end + + def within_feature_flag_row(index) + within ".gl-responsive-table-row:nth-child(#{index + 1})" do + yield + end + end + + def within_feature_flag_scopes + within '.js-feature-flag-environments' do + yield + end + end + + def within_scope_row(index) + within ".gl-responsive-table-row:nth-child(#{index + 1})" do + yield + end + end + + def within_strategy_row(index) + within ".feature-flags-form > fieldset > div[data-testid='feature-flag-strategies'] > div:nth-child(#{index})" do + yield + end + end + + def within_environment_spec + within '.table-section:nth-child(1)' do + yield + end + end + + def within_status + within '.table-section:nth-child(2)' do + yield + end + end + + def within_delete + within '.table-section:nth-child(4)' do + yield + end + end + + def edit_feature_flag_button + find('.js-feature-flag-edit-button') + end + + def delete_strategy_button + find("button[data-testid='delete-strategy-button']") + end + + def add_linked_issue_button + find('.js-issue-count-badge-add-button') + end + + def remove_linked_issue_button + find('.js-issue-item-remove-button') + end + + def status_toggle_button + find('[data-testid="feature-flag-status-toggle"] button') + end + + def expect_status_toggle_button_to_be_checked + expect(page).to have_css('[data-testid="feature-flag-status-toggle"] button.is-checked') + end + + def expect_status_toggle_button_not_to_be_checked + expect(page).to have_css('[data-testid="feature-flag-status-toggle"] button:not(.is-checked)') + end + + def expect_status_toggle_button_to_be_disabled + expect(page).to have_css('[data-testid="feature-flag-status-toggle"] button.is-disabled') + end + + def expect_user_to_see_feature_flags_index_page + expect(page).to have_text('Feature Flags') + expect(page).to have_text('Lists') + end +end diff --git a/spec/support/helpers/multipart_helpers.rb b/spec/support/helpers/multipart_helpers.rb new file mode 100644 index 00000000000..f068d5e102d --- /dev/null +++ b/spec/support/helpers/multipart_helpers.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module MultipartHelpers + def post_env(rewritten_fields:, params:, secret:, issuer:) + token = JWT.encode({ 'iss' => issuer, 'rewritten_fields' => rewritten_fields }, secret, 'HS256') + Rack::MockRequest.env_for( + '/', + method: 'post', + params: params, + described_class::RACK_ENV_KEY => token + ) + end + + # This function assumes a `mode` variable to be set + def upload_parameters_for(filepath: nil, key: nil, filename: 'filename', remote_id: 'remote_id') + result = { + "#{key}.name" => filename, + "#{key}.type" => "application/octet-stream", + "#{key}.sha256" => "1234567890" + } + + case mode + when :local + result["#{key}.path"] = filepath + when :remote + result["#{key}.remote_id"] = remote_id + result["#{key}.size"] = 3.megabytes + else + raise ArgumentError, "can't handle #{mode} mode" + end + + result + end + + # This function assumes a `mode` variable to be set + def rewritten_fields_hash(hash) + if mode == :remote + # For remote uploads, workhorse still submits rewritten_fields, + # but all the values are empty strings. + hash.keys.each { |k| hash[k] = '' } + end + + hash + end + + def expect_uploaded_files(uploaded_file_expectations) + expect(app).to receive(:call) do |env| + Array.wrap(uploaded_file_expectations).each do |expectation| + file = get_params(env).dig(*expectation[:params_path]) + expect_uploaded_file(file, expectation) + end + end + end + + # This function assumes a `mode` variable to be set + def expect_uploaded_file(file, expectation) + expect(file).to be_a(::UploadedFile) + expect(file.original_filename).to eq(expectation[:original_filename]) + expect(file.sha256).to eq('1234567890') + + case mode + when :local + expect(file.path).to eq(File.realpath(expectation[:filepath])) + expect(file.remote_id).to be_nil + expect(file.size).to eq(expectation[:size]) + when :remote + expect(file.remote_id).to eq(expectation[:remote_id]) + expect(file.path).to be_nil + expect(file.size).to eq(3.megabytes) + else + raise ArgumentError, "can't handle #{mode} mode" + end + end + + # Rails doesn't combine the GET/POST parameters in + # ActionDispatch::HTTP::Parameters if action_dispatch.request.parameters is set: + # https://github.com/rails/rails/blob/aea6423f013ca48f7704c70deadf2cd6ac7d70a1/actionpack/lib/action_dispatch/http/parameters.rb#L41 + def get_params(env) + req = ActionDispatch::Request.new(env) + req.GET.merge(req.POST) + end +end diff --git a/spec/support/shared_contexts/lib/gitlab/middleware/multipart_shared_contexts.rb b/spec/support/shared_contexts/lib/gitlab/middleware/multipart_shared_contexts.rb index f1554ea8e9f..ec5bea34e8b 100644 --- a/spec/support/shared_contexts/lib/gitlab/middleware/multipart_shared_contexts.rb +++ b/spec/support/shared_contexts/lib/gitlab/middleware/multipart_shared_contexts.rb @@ -1,42 +1,88 @@ # frozen_string_literal: true -RSpec.shared_context 'multipart middleware context' do - let(:app) { double(:app) } - let(:middleware) { described_class.new(app) } - let(:original_filename) { 'filename' } - - # Rails 5 doesn't combine the GET/POST parameters in - # ActionDispatch::HTTP::Parameters if action_dispatch.request.parameters is set: - # https://github.com/rails/rails/blob/aea6423f013ca48f7704c70deadf2cd6ac7d70a1/actionpack/lib/action_dispatch/http/parameters.rb#L41 - def get_params(env) - req = ActionDispatch::Request.new(env) - req.GET.merge(req.POST) +# This context provides one temporary file for the multipart spec +# +# Here are the available variables: +# - uploaded_file +# - uploaded_filepath +# - filename +# - remote_id +RSpec.shared_context 'with one temporary file for multipart' do |within_tmp_sub_dir: false| + let(:uploaded_filepath) { uploaded_file.path } + + around do |example| + Tempfile.open('uploaded_file2') do |tempfile| + @uploaded_file = tempfile + @filename = 'test_file.png' + @remote_id = 'remote_id' + + example.run + end end - def post_env(rewritten_fields, params, secret, issuer) - token = JWT.encode({ 'iss' => issuer, 'rewritten_fields' => rewritten_fields }, secret, 'HS256') - Rack::MockRequest.env_for( - '/', - method: 'post', - params: params, - described_class::RACK_ENV_KEY => token - ) + attr_reader :uploaded_file, :filename, :remote_id +end + +# This context provides two temporary files for the multipart spec +# +# Here are the available variables: +# - uploaded_file +# - uploaded_filepath +# - filename +# - remote_id +# - tmp_sub_dir (only when using within_tmp_sub_dir: true) +# - uploaded_file2 +# - uploaded_filepath2 +# - filename2 +# - remote_id2 +RSpec.shared_context 'with two temporary files for multipart' do + include_context 'with one temporary file for multipart' + + let(:uploaded_filepath2) { uploaded_file2.path } + + around do |example| + Tempfile.open('uploaded_file2') do |tempfile| + @uploaded_file2 = tempfile + @filename2 = 'test_file2.png' + @remote_id2 = 'remote_id2' + + example.run + end end - def with_tmp_dir(uploads_sub_dir, storage_path = '') - Dir.mktmpdir do |dir| - upload_dir = File.join(dir, storage_path, uploads_sub_dir) - FileUtils.mkdir_p(upload_dir) + attr_reader :uploaded_file2, :filename2, :remote_id2 +end + +# This context provides three temporary files for the multipart spec +# +# Here are the available variables: +# - uploaded_file +# - uploaded_filepath +# - filename +# - remote_id +# - tmp_sub_dir (only when using within_tmp_sub_dir: true) +# - uploaded_file2 +# - uploaded_filepath2 +# - filename2 +# - remote_id2 +# - uploaded_file3 +# - uploaded_filepath3 +# - filename3 +# - remote_id3 +RSpec.shared_context 'with three temporary files for multipart' do + include_context 'with two temporary files for multipart' - allow(Rails).to receive(:root).and_return(dir) - allow(Dir).to receive(:tmpdir).and_return(File.join(Dir.tmpdir, 'tmpsubdir')) - allow(GitlabUploader).to receive(:root).and_return(File.join(dir, storage_path)) + let(:uploaded_filepath3) { uploaded_file3.path } - Tempfile.open('top-level', upload_dir) do |tempfile| - env = post_env({ 'file' => tempfile.path }, { 'file.name' => original_filename, 'file.path' => tempfile.path }, Gitlab::Workhorse.secret, 'gitlab-workhorse') + around do |example| + Tempfile.open('uploaded_file3') do |tempfile| + @uploaded_file3 = tempfile + @filename3 = 'test_file3.png' + @remote_id3 = 'remote_id3' - yield dir, env - end + example.run end end + + attr_reader :uploaded_file3, :filename3, :remote_id3 end diff --git a/spec/support/shared_examples/lib/gitlab/middleware/multipart_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/middleware/multipart_shared_examples.rb new file mode 100644 index 00000000000..6327367fcc2 --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/middleware/multipart_shared_examples.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'handling all upload parameters conditions' do + context 'one root parameter' do + include_context 'with one temporary file for multipart' + + let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) } + let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'file', filename: filename, remote_id: remote_id) } + + it 'builds an UploadedFile' do + expect_uploaded_files(filepath: uploaded_filepath, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(file)) + + subject + end + end + + context 'two root parameters' do + include_context 'with two temporary files for multipart' + + let(:rewritten_fields) { rewritten_fields_hash('file1' => uploaded_filepath, 'file2' => uploaded_filepath2) } + let(:params) do + upload_parameters_for(filepath: uploaded_filepath, key: 'file1', filename: filename, remote_id: remote_id).merge( + upload_parameters_for(filepath: uploaded_filepath2, key: 'file2', filename: filename2, remote_id: remote_id2) + ) + end + + it 'builds UploadedFiles' do + expect_uploaded_files([ + { filepath: uploaded_filepath, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(file1) }, + { filepath: uploaded_filepath2, original_filename: filename2, remote_id: remote_id2, size: uploaded_file2.size, params_path: %w(file2) } + ]) + + subject + end + end + + context 'one nested parameter' do + include_context 'with one temporary file for multipart' + + let(:rewritten_fields) { rewritten_fields_hash('user[avatar]' => uploaded_filepath) } + let(:params) { { 'user' => { 'avatar' => upload_parameters_for(filepath: uploaded_filepath, filename: filename, remote_id: remote_id) } } } + + it 'builds an UploadedFile' do + expect_uploaded_files(filepath: uploaded_filepath, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(user avatar)) + + subject + end + end + + context 'two nested parameters' do + include_context 'with two temporary files for multipart' + + let(:rewritten_fields) { rewritten_fields_hash('user[avatar]' => uploaded_filepath, 'user[screenshot]' => uploaded_filepath2) } + let(:params) do + { + 'user' => { + 'avatar' => upload_parameters_for(filepath: uploaded_filepath, filename: filename, remote_id: remote_id), + 'screenshot' => upload_parameters_for(filepath: uploaded_filepath2, filename: filename2, remote_id: remote_id2) + } + } + end + + it 'builds UploadedFiles' do + expect_uploaded_files([ + { filepath: uploaded_filepath, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(user avatar) }, + { filepath: uploaded_filepath2, original_filename: filename2, remote_id: remote_id2, size: uploaded_file2.size, params_path: %w(user screenshot) } + ]) + + subject + end + end + + context 'one deeply nested parameter' do + include_context 'with one temporary file for multipart' + + let(:rewritten_fields) { rewritten_fields_hash('user[avatar][bananas]' => uploaded_filepath) } + let(:params) { { 'user' => { 'avatar' => { 'bananas' => upload_parameters_for(filepath: uploaded_filepath, filename: filename, remote_id: remote_id) } } } } + + it 'builds an UploadedFile' do + expect_uploaded_files(filepath: uploaded_file, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(user avatar bananas)) + + subject + end + end + + context 'two deeply nested parameters' do + include_context 'with two temporary files for multipart' + + let(:rewritten_fields) { rewritten_fields_hash('user[avatar][bananas]' => uploaded_filepath, 'user[friend][ananas]' => uploaded_filepath2) } + let(:params) do + { + 'user' => { + 'avatar' => { + 'bananas' => upload_parameters_for(filepath: uploaded_filepath, filename: filename, remote_id: remote_id) + }, + 'friend' => { + 'ananas' => upload_parameters_for(filepath: uploaded_filepath2, filename: filename2, remote_id: remote_id2) + } + } + } + end + + it 'builds UploadedFiles' do + expect_uploaded_files([ + { filepath: uploaded_file, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(user avatar bananas) }, + { filepath: uploaded_file2, original_filename: filename2, remote_id: remote_id2, size: uploaded_file2.size, params_path: %w(user friend ananas) } + ]) + + subject + end + end + + context 'three parameters nested at different levels' do + include_context 'with three temporary files for multipart' + + let(:rewritten_fields) do + rewritten_fields_hash( + 'file' => uploaded_filepath, + 'user[avatar]' => uploaded_filepath2, + 'user[friend][avatar]' => uploaded_filepath3 + ) + end + + let(:params) do + upload_parameters_for(filepath: uploaded_filepath, filename: filename, key: 'file', remote_id: remote_id).merge( + 'user' => { + 'avatar' => upload_parameters_for(filepath: uploaded_filepath2, filename: filename2, remote_id: remote_id2), + 'friend' => { + 'avatar' => upload_parameters_for(filepath: uploaded_filepath3, filename: filename3, remote_id: remote_id3) + } + } + ) + end + + it 'builds UploadedFiles' do + expect_uploaded_files([ + { filepath: uploaded_filepath, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(file) }, + { filepath: uploaded_filepath2, original_filename: filename2, remote_id: remote_id2, size: uploaded_file2.size, params_path: %w(user avatar) }, + { filepath: uploaded_filepath3, original_filename: filename3, remote_id: remote_id3, size: uploaded_file3.size, params_path: %w(user friend avatar) } + ]) + + subject + end + end +end diff --git a/spec/support/shared_examples/lib/gitlab/search_issue_state_filter_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/search_issue_state_filter_shared_examples.rb new file mode 100644 index 00000000000..e0e41aca331 --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/search_issue_state_filter_shared_examples.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'search issues scope filters by state' do + context 'state not provided' do + let(:filters) { {} } + + it 'returns opened and closed issues', :aggregate_failures do + expect(results.objects('issues')).to include opened_issue + expect(results.objects('issues')).to include closed_issue + end + end + + context 'all state' do + let(:filters) { { state: 'all' } } + + it 'returns opened and closed issues', :aggregate_failures do + expect(results.objects('issues')).to include opened_issue + expect(results.objects('issues')).to include closed_issue + end + end + + context 'closed state' do + let(:filters) { { state: 'closed' } } + + it 'returns only closed issues', :aggregate_failures do + expect(results.objects('issues')).not_to include opened_issue + expect(results.objects('issues')).to include closed_issue + end + end + + context 'opened state' do + let(:filters) { { state: 'opened' } } + + it 'returns only opened issues', :aggregate_failures do + expect(results.objects('issues')).to include opened_issue + expect(results.objects('issues')).not_to include closed_issue + end + end + + context 'unsupported state' do + let(:filters) { { state: 'hello' } } + + it 'returns only opened issues', :aggregate_failures do + expect(results.objects('issues')).to include opened_issue + expect(results.objects('issues')).to include closed_issue + end + end +end diff --git a/spec/views/search/_results.html.haml_spec.rb b/spec/views/search/_results.html.haml_spec.rb index cbd639c6a20..9e95dc40ff8 100644 --- a/spec/views/search/_results.html.haml_spec.rb +++ b/spec/views/search/_results.html.haml_spec.rb @@ -54,6 +54,12 @@ RSpec.describe 'search/_results' do expect(rendered).to have_selector('[data-track-event=click_text]') expect(rendered).to have_selector('[data-track-property=search_result]') end + + it 'renders the state filter drop down' do + render + + expect(rendered).to have_selector('#js-search-filter-by-state') + end end end end diff --git a/yarn.lock b/yarn.lock index 964fa2e46eb..027cd147c9c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -848,10 +848,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.161.0.tgz#661e8d19862dfba0e4c558e2eb6d64b402c1453e" integrity sha512-qsbboEICn08ZoEoAX/TuYygsFaXlzsCY+CfmdOzqvJbOdfHhVXmrJBxd2hP2qqjTZm2PkbRRmn+03+ce1jvatQ== -"@gitlab/ui@20.18.0": - version "20.18.0" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-20.18.0.tgz#f308c444bcd2d0f09fb9ca358c97dd8817ea5598" - integrity sha512-JSIK7qHyQf0jAALUn9igOPSi6fIPNZyen7C0L2HFBzi5WIwYNIrV/4/uFfwdG/5fHvtPvTCbjFRRwOrP2IwCNA== +"@gitlab/ui@20.18.1": + version "20.18.1" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-20.18.1.tgz#c8a1e0830b63c056999b9417a499677fd46659af" + integrity sha512-WbLBP6Ni8YxKqlKOZChmedc8uS7MRm5CYg/k3mRUELydF/LoW4/M0CsKwgplW4OJfEQRJr8bvmjiTLAyKAky4g== dependencies: "@babel/standalone" "^7.0.0" "@gitlab/vue-toasted" "^1.3.0" @@ -8371,10 +8371,10 @@ monaco-yaml@^2.4.1: optionalDependencies: prettier "^1.19.1" -mousetrap@^1.4.6: - version "1.4.6" - resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.4.6.tgz#eaca72e22e56d5b769b7555873b688c3332e390a" - integrity sha1-6spy4i5W1bdpt1VYc7aIwzMuOQo= +mousetrap@1.6.5: + version "1.6.5" + resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.5.tgz#8a766d8c272b08393d5f56074e0b5ec183485bf9" + integrity sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA== move-concurrently@^1.0.1: version "1.0.1" -- GitLab